dev(data-access): implement .read() method

Most of this implementation matched `.select()` which saved time. The
common behavior can be factored out, but since the purpose of this
refactor is to flatten everything as much as possible I'm going to leave
the duplicated code and make a note to do this later.

The challenging part was `#build_complex_id_where()`, which is the
equivalent to the `fetch_based_on_complex_id_()` method in
EntityStoreService. This allows `uid: app-UUID-OF-APP` and `id: { name:
'editor' }` to both work.
This commit is contained in:
KernelDeimos
2026-01-20 16:28:22 -05:00
committed by Eric Dubé
parent 2bb036616f
commit ec0b333ad7
@@ -1,3 +1,4 @@
import APIError from '../../api/APIError.js';
import BaseService from '../../services/BaseService.js';
import { DB_READ } from '../../services/database/consts.js';
import { Context } from '../../util/context.js';
@@ -27,7 +28,7 @@ export default class AppService extends BaseService {
// TODO
},
async read ({ uid, id, params = {} }) {
// TODO
return this.#read({ uid, id, params });
},
async select (options) {
return this.#select(options);
@@ -38,6 +39,9 @@ export default class AppService extends BaseService {
},
};
// value of require('om/mappings/app.js').redundant_identifiers
static REDUNDANT_IDENTIFIERS = ['name'];
async #select ({ predicate, params, ...rest }) {
const db = this.db;
@@ -146,4 +150,161 @@ export default class AppService extends BaseService {
return client_safe_apps;
}
async #read ({ uid, id, params = {} }) {
const db = this.db;
if ( uid === undefined && id === undefined ) {
throw new Error('read requires either uid or id');
}
const sql_associatedFiletypes = this.db.case({
mysql: 'COALESCE(JSON_ARRAYAGG(afa.type), JSON_ARRAY())',
sqlite: "COALESCE(json_group_array(afa.type), json('[]'))",
});
// Build WHERE clause based on identifier type
let whereClause;
let whereValues;
if ( uid !== undefined ) {
// Simple uid lookup
whereClause = 'apps.uid = ?';
whereValues = [uid];
} else if ( id !== null && typeof id === 'object' && !Array.isArray(id) ) {
// Complex id lookup (e.g., { name: 'editor' })
const { clause, values } = this.#build_complex_id_where(id);
whereClause = clause;
whereValues = values;
} else {
throw APIError.create('invalid_id', null, { id });
}
const stmt = 'SELECT apps.*, ' +
'owner_user.username AS owner_user_username, ' +
'owner_user.uuid AS owner_user_uuid, ' +
'app_owner.uid AS app_owner_uid, ' +
`${sql_associatedFiletypes} AS filetypes ` +
'FROM apps ' +
'LEFT JOIN user owner_user ON apps.owner_user_id = owner_user.id ' +
'LEFT JOIN apps app_owner ON apps.app_owner = app_owner.id ' +
'LEFT JOIN app_filetype_association afa ON apps.id = afa.app_id ' +
`WHERE ${whereClause} ` +
'GROUP BY apps.id ' +
'LIMIT 1';
const rows = await db.read(stmt, whereValues);
if ( rows.length === 0 ) {
return undefined;
}
const row = rows[0];
const app = {};
app.approved_for_incentive_program = as_bool(row.approved_for_incentive_program);
app.approved_for_listing = as_bool(row.approved_for_listing);
app.approved_for_opening_items = as_bool(row.approved_for_opening_items);
app.background = as_bool(row.background);
app.created_at = row.created_at;
app.created_from_origin = row.created_from_origin;
app.description = row.description;
app.godmode = as_bool(row.godmode);
app.icon = row.icon;
app.index_url = row.index_url;
app.maximize_on_start = as_bool(row.maximize_on_start);
app.metadata = row.metadata;
app.name = row.name;
app.protected = as_bool(row.protected);
app.stats = row.stats;
app.title = row.title;
app.uid = row.uid;
app.app_owner = {
uid: row.app_owner_uid,
};
{
const owner_user = extract_from_prefix(row, 'owner_user_');
app.owner_user = user_to_client(owner_user);
}
if ( typeof row.filetypes === 'string' ) {
try {
const filetypesAsJSON = JSON.parse(row.filetypes);
for ( let i = 0 ; i < filetypesAsJSON.length ; i++ ) {
if ( filetypesAsJSON[i] === null ) continue;
if ( typeof filetypesAsJSON[i] !== 'string' ) {
throw new Error(`expected filetypesAsJSON[${i}] to be a string, got: ${filetypesAsJSON[i]}`);
}
if ( String.prototype.startsWith.call(filetypesAsJSON[i], '.') ) {
filetypesAsJSON[i] = filetypesAsJSON[i].slice(1);
}
}
app.filetype_associations = filetypesAsJSON;
} catch (e) {
throw new Error(`failed to get app filetype associations: ${e.message}`, { cause: e });
}
}
if ( params.icon_size ) {
const icon_size = params.icon_size;
const svc_appIcon = this.context.get('services').get('app-icon');
try {
const icon_result = await svc_appIcon.get_icon_stream({
app_uid: row.uid,
app_icon: row.icon,
size: icon_size,
});
app.icon = await icon_result.get_data_url();
} catch (e) {
const svc_error = this.context.get('services').get('error-service');
svc_error.report('AppES:read_transform', { source: e });
}
}
return app;
}
#build_complex_id_where (id) {
const id_keys = Object.keys(id);
id_keys.sort();
// 1. Validate the identifier key from `id`
const redundant_identifiers = this.constructor.REDUNDANT_IDENTIFIERS;
let match_found = false;
for ( let key_set of redundant_identifiers ) {
key_set = Array.isArray(key_set) ? key_set : [key_set];
const sorted_key_set = [...key_set].sort();
// Check if id_keys matches this key_set exactly
if ( id_keys.length === sorted_key_set.length &&
id_keys.every((k, i) => k === sorted_key_set[i]) ) {
match_found = true;
break;
}
}
if ( ! match_found ) {
throw new Error(`Invalid complex id keys: ${id_keys.join(', ')}. ` +
`Allowed: ${redundant_identifiers.join(', ')}`);
}
// 2. Build the SQL string for the predicate
const conditions = [];
const values = [];
for ( const key of id_keys ) {
conditions.push(`apps.${key} = ?`);
values.push(id[key]);
}
return {
clause: conditions.join(' AND '),
values,
};
}
}