@yunsoft/yuncms-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +5 -0
  3. package/package.json +36 -0
  4. package/src/accountability.js +39 -0
  5. package/src/advisory-lock.js +30 -0
  6. package/src/auth/password.js +100 -0
  7. package/src/auth/tokens.js +38 -0
  8. package/src/auth/users-repository.js +25 -0
  9. package/src/bootstrap.js +41 -0
  10. package/src/config.js +106 -0
  11. package/src/context.js +33 -0
  12. package/src/database.js +31 -0
  13. package/src/errors.js +35 -0
  14. package/src/field-types.js +110 -0
  15. package/src/hooks.js +121 -0
  16. package/src/identifier.js +13 -0
  17. package/src/index.js +67 -0
  18. package/src/logger.js +56 -0
  19. package/src/m2m-lifecycle.js +139 -0
  20. package/src/mail/smtp-mailer.js +65 -0
  21. package/src/migrations/0001-system-schema.js +164 -0
  22. package/src/migrations/0002-session-access-tokens.js +10 -0
  23. package/src/migrations/0003-public-role-constraints.js +10 -0
  24. package/src/migrations/0004-auth-action-tokens.js +19 -0
  25. package/src/migrations.js +81 -0
  26. package/src/permission-validation.js +63 -0
  27. package/src/query.js +193 -0
  28. package/src/relation-expansion.js +216 -0
  29. package/src/retry.js +29 -0
  30. package/src/schema-metadata-repository.js +284 -0
  31. package/src/schema-version.js +21 -0
  32. package/src/schema.js +82 -0
  33. package/src/services/api-tokens-service.js +117 -0
  34. package/src/services/audit-service.js +182 -0
  35. package/src/services/auth-service.js +164 -0
  36. package/src/services/auth-tokens-service.js +215 -0
  37. package/src/services/base-service.js +26 -0
  38. package/src/services/collections-service.js +249 -0
  39. package/src/services/core-services.js +32 -0
  40. package/src/services/fields-service.js +471 -0
  41. package/src/services/file-reconciliation-service.js +127 -0
  42. package/src/services/files-service.js +227 -0
  43. package/src/services/items-service.js +445 -0
  44. package/src/services/permissions-service.js +282 -0
  45. package/src/services/relations-service.js +455 -0
  46. package/src/services/roles-service.js +160 -0
  47. package/src/services/schema-access.js +7 -0
  48. package/src/services/service-registry.js +32 -0
  49. package/src/services/sessions-service.js +179 -0
  50. package/src/services/users-service.js +215 -0
  51. package/src/setup.js +66 -0
  52. package/src/storage/local-storage-driver.js +105 -0
  53. package/src/storage/s3-storage-driver.js +150 -0
  54. package/src/storage/storage-registry.js +39 -0
  55. package/src/transaction.js +46 -0
@@ -0,0 +1,81 @@
1
+ const JOURNAL_TABLE = 'yuncms_schema_migrations';
2
+
3
+ export async function ensureMigrationJournal(database) {
4
+ await database.query(`
5
+ CREATE TABLE IF NOT EXISTS ${JOURNAL_TABLE} (
6
+ id VARCHAR(191) NOT NULL PRIMARY KEY,
7
+ applied_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
8
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
9
+ `);
10
+ }
11
+
12
+ export async function readAppliedMigrations(database) {
13
+ const [rows] = await database.query(`SELECT id FROM ${JOURNAL_TABLE} ORDER BY id ASC`);
14
+ return new Set(rows.map((row) => row.id));
15
+ }
16
+
17
+ export function validateMigration(migration) {
18
+ if (!migration || typeof migration !== 'object') throw new Error('Migration must be an object');
19
+ if (!migration.id || typeof migration.id !== 'string') throw new Error('Migration id is required');
20
+ if (!Array.isArray(migration.statements) || migration.statements.length === 0) {
21
+ throw new Error(`Migration ${migration.id} must contain statements`);
22
+ }
23
+ if (migration.statements.some((statement) => typeof statement !== 'string' || !statement.trim())) {
24
+ throw new Error(`Migration ${migration.id} contains an invalid statement`);
25
+ }
26
+ return migration;
27
+ }
28
+
29
+ export async function applyMigrations(database, migrations) {
30
+ if (!database) throw new Error('Database handle is required');
31
+ if (!Array.isArray(migrations)) throw new Error('Migrations must be an array');
32
+
33
+ await ensureMigrationJournal(database);
34
+ const applied = await readAppliedMigrations(database);
35
+ const newlyApplied = [];
36
+
37
+ for (const rawMigration of migrations) {
38
+ const migration = validateMigration(rawMigration);
39
+ if (applied.has(migration.id)) continue;
40
+
41
+ for (const statement of migration.statements) {
42
+ await database.query(statement);
43
+ }
44
+
45
+ await database.query(`INSERT INTO ${JOURNAL_TABLE} (id) VALUES (?)`, [migration.id]);
46
+ applied.add(migration.id);
47
+ newlyApplied.push(migration.id);
48
+ }
49
+
50
+ return {
51
+ applied: [...applied].sort(),
52
+ newlyApplied,
53
+ };
54
+ }
55
+
56
+ export async function assertMigrationsApplied(database, requiredMigrationIds) {
57
+ let applied;
58
+
59
+ try {
60
+ applied = await readAppliedMigrations(database);
61
+ } catch (error) {
62
+ if (error?.code === 'ER_NO_SUCH_TABLE') {
63
+ const migrationError = new Error('Database bootstrap is required');
64
+ migrationError.code = 'DATABASE_MIGRATION_REQUIRED';
65
+ migrationError.missingMigrations = [...requiredMigrationIds];
66
+ throw migrationError;
67
+ }
68
+ throw error;
69
+ }
70
+
71
+ const missing = requiredMigrationIds.filter((id) => !applied.has(id));
72
+
73
+ if (missing.length > 0) {
74
+ const error = new Error(`Database bootstrap is incomplete. Missing migrations: ${missing.join(', ')}`);
75
+ error.code = 'DATABASE_MIGRATION_REQUIRED';
76
+ error.missingMigrations = missing;
77
+ throw error;
78
+ }
79
+
80
+ return true;
81
+ }
@@ -0,0 +1,63 @@
1
+ import { compileFilter } from './query.js';
2
+
3
+ function validationError(message, path = null) {
4
+ const error = new Error(message);
5
+ error.code = 'VALIDATION_FAILED';
6
+ if (path) error.path = path;
7
+ return error;
8
+ }
9
+
10
+ function compare(operator, actual, expected) {
11
+ switch (operator) {
12
+ case '_eq': return actual === expected;
13
+ case '_neq': return actual !== expected;
14
+ case '_lt': return actual != null && actual < expected;
15
+ case '_lte': return actual != null && actual <= expected;
16
+ case '_gt': return actual != null && actual > expected;
17
+ case '_gte': return actual != null && actual >= expected;
18
+ case '_in': return expected.some((candidate) => actual === candidate);
19
+ case '_nin': return !expected.some((candidate) => actual === candidate);
20
+ case '_null': return expected ? actual == null : actual != null;
21
+ case '_nnull': return expected ? actual != null : actual == null;
22
+ case '_contains': return actual != null && String(actual).includes(String(expected));
23
+ case '_starts_with': return actual != null && String(actual).startsWith(String(expected));
24
+ case '_ends_with': return actual != null && String(actual).endsWith(String(expected));
25
+ default: return false;
26
+ }
27
+ }
28
+
29
+ function evaluateNode(record, node) {
30
+ for (const [key, value] of Object.entries(node)) {
31
+ if (key === '_and') {
32
+ if (!value.every((child) => evaluateNode(record, child))) return false;
33
+ continue;
34
+ }
35
+ if (key === '_or') {
36
+ if (!value.some((child) => evaluateNode(record, child))) return false;
37
+ continue;
38
+ }
39
+
40
+ const actual = record[key];
41
+ for (const [operator, expected] of Object.entries(value)) {
42
+ if (!compare(operator, actual, expected)) return false;
43
+ }
44
+ }
45
+ return true;
46
+ }
47
+
48
+ export function assertPermissionValidationRule(rule, schema) {
49
+ if (rule == null) return null;
50
+ compileFilter(rule, schema);
51
+ return rule;
52
+ }
53
+
54
+ export function evaluatePermissionValidation(record, rule, schema) {
55
+ if (rule == null) return true;
56
+ assertPermissionValidationRule(rule, schema);
57
+ return evaluateNode(record, rule);
58
+ }
59
+
60
+ export function enforcePermissionValidation(record, rule, schema, { path = 'validation' } = {}) {
61
+ if (evaluatePermissionValidation(record, rule, schema)) return record;
62
+ throw validationError('Record does not satisfy the permission validation rule', path);
63
+ }
package/src/query.js ADDED
@@ -0,0 +1,193 @@
1
+ import { quoteIdentifier } from './identifier.js';
2
+
3
+ const QUERY_KEYS = new Set(['fields', 'filter', 'sort', 'limit', 'offset']);
4
+ const FILTER_OPERATORS = new Set([
5
+ '_eq', '_neq', '_lt', '_lte', '_gt', '_gte',
6
+ '_in', '_nin', '_null', '_nnull',
7
+ '_contains', '_starts_with', '_ends_with',
8
+ ]);
9
+
10
+ function queryError(message, path = null) {
11
+ const error = new Error(message);
12
+ error.code = 'INVALID_QUERY';
13
+ if (path) error.path = path;
14
+ return error;
15
+ }
16
+
17
+ function normalizeDelimited(value, label) {
18
+ if (value == null || value === '') return null;
19
+ const values = Array.isArray(value) ? value : String(value).split(',');
20
+ const normalized = values.map((item) => String(item).trim()).filter(Boolean);
21
+ if (normalized.length === 0) throw queryError(`${label} cannot be empty`, label);
22
+ return normalized;
23
+ }
24
+
25
+ function normalizeInteger(value, fallback, { label, min, max }) {
26
+ if (value == null || value === '') return fallback;
27
+ const number = Number(value);
28
+ if (!Number.isInteger(number) || number < min || number > max) {
29
+ throw queryError(`${label} must be an integer between ${min} and ${max}`, label);
30
+ }
31
+ return number;
32
+ }
33
+
34
+ function normalizeFilter(value) {
35
+ if (value == null || value === '') return null;
36
+ if (typeof value === 'string') {
37
+ try {
38
+ const parsed = JSON.parse(value);
39
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
40
+ throw new Error('not an object');
41
+ }
42
+ return parsed;
43
+ } catch {
44
+ throw queryError('filter must be a valid JSON object', 'filter');
45
+ }
46
+ }
47
+ if (typeof value !== 'object' || Array.isArray(value)) {
48
+ throw queryError('filter must be an object', 'filter');
49
+ }
50
+ return value;
51
+ }
52
+
53
+ export function parseItemsQuery(raw = {}, { defaultLimit = 100, maxLimit = 500 } = {}) {
54
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
55
+ throw queryError('Query must be an object');
56
+ }
57
+
58
+ for (const key of Object.keys(raw)) {
59
+ if (!QUERY_KEYS.has(key)) throw queryError(`Unknown query parameter: ${key}`, key);
60
+ }
61
+
62
+ return {
63
+ fields: normalizeDelimited(raw.fields, 'fields'),
64
+ filter: normalizeFilter(raw.filter),
65
+ sort: normalizeDelimited(raw.sort, 'sort'),
66
+ limit: normalizeInteger(raw.limit, defaultLimit, { label: 'limit', min: 1, max: maxLimit }),
67
+ offset: normalizeInteger(raw.offset, 0, { label: 'offset', min: 0, max: Number.MAX_SAFE_INTEGER }),
68
+ };
69
+ }
70
+
71
+ function resolveField(schema, field, path = field) {
72
+ if (!schema?.fields?.[field]) throw queryError(`Unknown field: ${field}`, path);
73
+ return schema.fields[field];
74
+ }
75
+
76
+ export function compileSelectFields(fields, schema) {
77
+ const selected = !fields || fields.includes('*') ? Object.keys(schema.fields) : fields;
78
+ if (selected.length === 0) throw queryError('At least one field must be selected', 'fields');
79
+
80
+ const unique = [...new Set(selected)];
81
+ return {
82
+ fields: unique,
83
+ sql: unique.map((field) => {
84
+ resolveField(schema, field, `fields.${field}`);
85
+ return quoteIdentifier(field, 'field name');
86
+ }).join(', '),
87
+ };
88
+ }
89
+
90
+ export function compileSort(sort, schema) {
91
+ if (!sort) return '';
92
+
93
+ const parts = sort.map((entry, index) => {
94
+ const descending = entry.startsWith('-');
95
+ const field = descending ? entry.slice(1) : entry;
96
+ if (!field) throw queryError('Sort field cannot be empty', `sort.${index}`);
97
+ resolveField(schema, field, `sort.${index}`);
98
+ return `${quoteIdentifier(field, 'field name')} ${descending ? 'DESC' : 'ASC'}`;
99
+ });
100
+
101
+ return parts.length ? ` ORDER BY ${parts.join(', ')}` : '';
102
+ }
103
+
104
+ function escapeLike(value) {
105
+ return String(value).replace(/[\\%_]/g, '\\$&');
106
+ }
107
+
108
+ function compileOperator(fieldSql, operator, value, path) {
109
+ if (!FILTER_OPERATORS.has(operator)) throw queryError(`Unknown filter operator: ${operator}`, path);
110
+
111
+ switch (operator) {
112
+ case '_eq':
113
+ if (value === null) throw queryError('Use _null for NULL comparisons', path);
114
+ return { sql: `${fieldSql} = ?`, params: [value] };
115
+ case '_neq':
116
+ if (value === null) throw queryError('Use _nnull for NULL comparisons', path);
117
+ return { sql: `${fieldSql} <> ?`, params: [value] };
118
+ case '_lt': return { sql: `${fieldSql} < ?`, params: [value] };
119
+ case '_lte': return { sql: `${fieldSql} <= ?`, params: [value] };
120
+ case '_gt': return { sql: `${fieldSql} > ?`, params: [value] };
121
+ case '_gte': return { sql: `${fieldSql} >= ?`, params: [value] };
122
+ case '_in':
123
+ case '_nin': {
124
+ if (!Array.isArray(value)) throw queryError(`${operator} requires an array`, path);
125
+ if (value.length === 0) return { sql: operator === '_in' ? '0 = 1' : '1 = 1', params: [] };
126
+ const placeholders = value.map(() => '?').join(', ');
127
+ return {
128
+ sql: `${fieldSql} ${operator === '_in' ? 'IN' : 'NOT IN'} (${placeholders})`,
129
+ params: value,
130
+ };
131
+ }
132
+ case '_null':
133
+ case '_nnull': {
134
+ if (typeof value !== 'boolean') throw queryError(`${operator} requires a boolean`, path);
135
+ const wantsNull = operator === '_null' ? value : !value;
136
+ return { sql: `${fieldSql} IS ${wantsNull ? '' : 'NOT '}NULL`, params: [] };
137
+ }
138
+ case '_contains':
139
+ return { sql: `${fieldSql} LIKE ? ESCAPE '\\\\'`, params: [`%${escapeLike(value)}%`] };
140
+ case '_starts_with':
141
+ return { sql: `${fieldSql} LIKE ? ESCAPE '\\\\'`, params: [`${escapeLike(value)}%`] };
142
+ case '_ends_with':
143
+ return { sql: `${fieldSql} LIKE ? ESCAPE '\\\\'`, params: [`%${escapeLike(value)}`] };
144
+ default:
145
+ throw queryError(`Unknown filter operator: ${operator}`, path);
146
+ }
147
+ }
148
+
149
+ function compileFilterObject(filter, schema, path = 'filter') {
150
+ if (!filter || typeof filter !== 'object' || Array.isArray(filter)) {
151
+ throw queryError('Filter node must be an object', path);
152
+ }
153
+
154
+ const fragments = [];
155
+ const params = [];
156
+
157
+ for (const [key, value] of Object.entries(filter)) {
158
+ if (key === '_and' || key === '_or') {
159
+ if (!Array.isArray(value) || value.length === 0) {
160
+ throw queryError(`${key} requires a non-empty array`, `${path}.${key}`);
161
+ }
162
+ const children = value.map((child, index) =>
163
+ compileFilterObject(child, schema, `${path}.${key}.${index}`));
164
+ fragments.push(`(${children.map((child) => child.sql).join(key === '_and' ? ' AND ' : ' OR ')})`);
165
+ for (const child of children) params.push(...child.params);
166
+ continue;
167
+ }
168
+
169
+ resolveField(schema, key, `${path}.${key}`);
170
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
171
+ throw queryError('Field filters must be operator objects', `${path}.${key}`);
172
+ }
173
+
174
+ const fieldSql = quoteIdentifier(key, 'field name');
175
+ const fieldFragments = [];
176
+ for (const [operator, operatorValue] of Object.entries(value)) {
177
+ const compiled = compileOperator(fieldSql, operator, operatorValue, `${path}.${key}.${operator}`);
178
+ fieldFragments.push(compiled.sql);
179
+ params.push(...compiled.params);
180
+ }
181
+ if (fieldFragments.length === 0) throw queryError('Field filter cannot be empty', `${path}.${key}`);
182
+ fragments.push(`(${fieldFragments.join(' AND ')})`);
183
+ }
184
+
185
+ if (fragments.length === 0) throw queryError('Filter cannot be empty', path);
186
+ return { sql: fragments.join(' AND '), params };
187
+ }
188
+
189
+ export function compileFilter(filter, schema) {
190
+ if (!filter) return { sql: '', params: [] };
191
+ const compiled = compileFilterObject(filter, schema);
192
+ return { sql: ` WHERE ${compiled.sql}`, params: compiled.params };
193
+ }
@@ -0,0 +1,216 @@
1
+ import { assertIdentifier } from './identifier.js';
2
+ import { SchemaCache } from './schema.js';
3
+ import { ItemsService } from './services/items-service.js';
4
+
5
+ const MAX_EXPAND_FIELDS = 8;
6
+ const defaultSchemaCache = new SchemaCache();
7
+
8
+ function expansionError(code, message, path = null) {
9
+ const error = new Error(message);
10
+ error.code = code;
11
+ if (path) error.path = path;
12
+ return error;
13
+ }
14
+
15
+ function normalizeDelimited(value) {
16
+ if (value == null || value === '') return [];
17
+ const values = Array.isArray(value) ? value : String(value).split(',');
18
+ return values.map((entry) => String(entry).trim()).filter(Boolean);
19
+ }
20
+
21
+ export function parseExpandInput(value) {
22
+ const fields = [...new Set(normalizeDelimited(value))];
23
+ if (fields.length > MAX_EXPAND_FIELDS) {
24
+ throw expansionError(
25
+ 'INVALID_QUERY',
26
+ `expand supports at most ${MAX_EXPAND_FIELDS} direct relation fields`,
27
+ 'expand',
28
+ );
29
+ }
30
+
31
+ for (const field of fields) {
32
+ try {
33
+ assertIdentifier(field, 'expand field');
34
+ } catch {
35
+ throw expansionError('INVALID_QUERY', `Invalid expand field: ${field}`, 'expand');
36
+ }
37
+ }
38
+ return fields;
39
+ }
40
+
41
+ function parseRelationMetadata(value) {
42
+ if (value == null || typeof value === 'object') return value ?? {};
43
+ try {
44
+ return JSON.parse(value);
45
+ } catch {
46
+ return {};
47
+ }
48
+ }
49
+
50
+ function withExpansionFields(rawFields, expandFields) {
51
+ if (rawFields == null || rawFields === '') return rawFields;
52
+ const selected = normalizeDelimited(rawFields);
53
+ if (selected.includes('*')) return selected;
54
+ return [...new Set([...selected, ...expandFields])];
55
+ }
56
+
57
+ function withoutExpand(query = {}, expandFields = []) {
58
+ const base = { ...query };
59
+ delete base.expand;
60
+ if (expandFields.length > 0 && Object.hasOwn(base, 'fields')) {
61
+ base.fields = withExpansionFields(base.fields, expandFields);
62
+ }
63
+ return base;
64
+ }
65
+
66
+ async function schemaSnapshot(options) {
67
+ if (options.schema) return options.schema;
68
+ const cache = options.schemaCache ?? defaultSchemaCache;
69
+ return cache.get(options.database);
70
+ }
71
+
72
+ function directRelation(snapshot, collection, field) {
73
+ const relation = snapshot.relationByManyField?.get(`${collection}.${field}`);
74
+ if (!relation) {
75
+ throw expansionError(
76
+ 'INVALID_QUERY',
77
+ `Field is not a direct relation and cannot be expanded: ${collection}.${field}`,
78
+ `expand.${field}`,
79
+ );
80
+ }
81
+
82
+ const metadata = parseRelationMetadata(relation.metadata);
83
+ if (relation.junction_collection || metadata.kind === 'm2m') {
84
+ throw expansionError(
85
+ 'UNSUPPORTED_RELATION_EXPANSION',
86
+ `Only direct M2O fields can be expanded in V1: ${collection}.${field}`,
87
+ `expand.${field}`,
88
+ );
89
+ }
90
+ return relation;
91
+ }
92
+
93
+ async function validateSourceExpansions({ collection, expandFields, options, service }) {
94
+ const snapshot = await schemaSnapshot(options);
95
+ const sourceSchema = snapshot.collections?.[collection];
96
+ if (!sourceSchema) {
97
+ throw expansionError('COLLECTION_NOT_FOUND', `Unknown collection: ${collection}`);
98
+ }
99
+ if (expandFields.length === 0) return snapshot;
100
+
101
+ const permission = await service.resolvePermission('read');
102
+ for (const field of expandFields) {
103
+ if (!sourceSchema.fields?.[field] || (permission.fields && !permission.fields.includes(field))) {
104
+ throw expansionError('INVALID_QUERY', `Unknown field: ${field}`, `expand.${field}`);
105
+ }
106
+ directRelation(snapshot, collection, field);
107
+ }
108
+ return snapshot;
109
+ }
110
+
111
+ async function expandRows({ collection, rows, expandFields, options, ItemsServiceClass, snapshot }) {
112
+ if (expandFields.length === 0 || rows.length === 0) return rows;
113
+ const effectiveSnapshot = snapshot ?? await schemaSnapshot(options);
114
+ let expandedRows = rows.map((row) => ({ ...row }));
115
+
116
+ for (const field of expandFields) {
117
+ const relation = directRelation(effectiveSnapshot, collection, field);
118
+ const targetCollection = relation.one_collection;
119
+ const targetKey = relation.one_field
120
+ || effectiveSnapshot.collections?.[targetCollection]?.primary_key
121
+ || 'id';
122
+ const values = [...new Set(
123
+ expandedRows
124
+ .map((row) => row[field])
125
+ .filter((value) => value != null && value !== '')
126
+ .map((value) => String(value)),
127
+ )];
128
+
129
+ if (values.length === 0) {
130
+ expandedRows = expandedRows.map((row) => ({ ...row, [field]: null }));
131
+ continue;
132
+ }
133
+
134
+ const targetService = new ItemsServiceClass(targetCollection, options);
135
+ const targetRows = await targetService.readMany({
136
+ filter: { [targetKey]: { _in: values } },
137
+ limit: Math.min(values.length, 500),
138
+ });
139
+
140
+ const byKey = new Map();
141
+ for (const target of targetRows) {
142
+ if (!Object.hasOwn(target, targetKey)) {
143
+ throw expansionError(
144
+ 'FORBIDDEN_FIELD',
145
+ `Expanded relation key is not readable: ${targetCollection}.${targetKey}`,
146
+ `expand.${field}`,
147
+ );
148
+ }
149
+ byKey.set(String(target[targetKey]), target);
150
+ }
151
+
152
+ expandedRows = expandedRows.map((row) => ({
153
+ ...row,
154
+ [field]: row[field] == null || row[field] === ''
155
+ ? null
156
+ : (byKey.get(String(row[field])) ?? null),
157
+ }));
158
+ }
159
+
160
+ return expandedRows;
161
+ }
162
+
163
+ export async function readManyWithRelations({
164
+ collection,
165
+ query = {},
166
+ options = {},
167
+ ItemsServiceClass = ItemsService,
168
+ } = {}) {
169
+ assertIdentifier(collection, 'collection name');
170
+ const expandFields = parseExpandInput(query.expand);
171
+ const service = new ItemsServiceClass(collection, options);
172
+ const snapshot = await validateSourceExpansions({ collection, expandFields, options, service });
173
+ const result = await service.readManyWithMeta(withoutExpand(query, expandFields));
174
+ const data = await expandRows({
175
+ collection,
176
+ rows: result.data,
177
+ expandFields,
178
+ options,
179
+ ItemsServiceClass,
180
+ snapshot,
181
+ });
182
+ return { ...result, data };
183
+ }
184
+
185
+ export async function readOneWithRelations({
186
+ collection,
187
+ id,
188
+ query = {},
189
+ options = {},
190
+ ItemsServiceClass = ItemsService,
191
+ } = {}) {
192
+ assertIdentifier(collection, 'collection name');
193
+ for (const key of Object.keys(query ?? {})) {
194
+ if (!['fields', 'expand'].includes(key)) {
195
+ throw expansionError('INVALID_QUERY', `Unknown query parameter: ${key}`, key);
196
+ }
197
+ }
198
+
199
+ const expandFields = parseExpandInput(query.expand);
200
+ const service = new ItemsServiceClass(collection, options);
201
+ const snapshot = await validateSourceExpansions({ collection, expandFields, options, service });
202
+ const fields = withExpansionFields(query.fields ?? null, expandFields);
203
+ const record = await service.readOne(id, { fields });
204
+ if (!record) return null;
205
+ const [expanded] = await expandRows({
206
+ collection,
207
+ rows: [record],
208
+ expandFields,
209
+ options,
210
+ ItemsServiceClass,
211
+ snapshot,
212
+ });
213
+ return expanded;
214
+ }
215
+
216
+ export { MAX_EXPAND_FIELDS };
package/src/retry.js ADDED
@@ -0,0 +1,29 @@
1
+ import { isRetryableDatabaseError } from './errors.js';
2
+
3
+ function defaultSleep(ms) {
4
+ return new Promise((resolve) => setTimeout(resolve, ms));
5
+ }
6
+
7
+ export async function withDatabaseRetry(operation, options = {}) {
8
+ const maxAttempts = options.maxAttempts ?? 3;
9
+ const baseDelayMs = options.baseDelayMs ?? 25;
10
+ const sleep = options.sleep ?? defaultSleep;
11
+
12
+ if (!Number.isInteger(maxAttempts) || maxAttempts < 1 || maxAttempts > 10) {
13
+ throw new Error('maxAttempts must be an integer between 1 and 10');
14
+ }
15
+
16
+ let attempt = 0;
17
+ while (attempt < maxAttempts) {
18
+ attempt += 1;
19
+
20
+ try {
21
+ return await operation({ attempt, maxAttempts });
22
+ } catch (error) {
23
+ if (!isRetryableDatabaseError(error) || attempt >= maxAttempts) throw error;
24
+ await sleep(baseDelayMs * attempt);
25
+ }
26
+ }
27
+
28
+ throw new Error('Database retry loop exited unexpectedly');
29
+ }