@stndrds/schema 0.1.0-alpha.20 → 0.1.0-alpha.21

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.
package/dist/runtime.js CHANGED
@@ -134,16 +134,22 @@ __export(runtime_exports, {
134
134
  NoopHookRegistry: () => NoopHookRegistry,
135
135
  ObjectSchemaService: () => ObjectSchemaService,
136
136
  PermissionService: () => PermissionService,
137
+ QueryBuilder: () => QueryBuilder,
138
+ QueryMultipleResultsError: () => QueryMultipleResultsError,
139
+ QueryNoResultError: () => QueryNoResultError,
137
140
  RecordService: () => RecordService,
138
141
  RelationResolverService: () => RelationResolverService,
139
142
  RelationService: () => RelationService,
140
143
  RollupScheduler: () => RollupScheduler,
141
144
  RollupService: () => RollupService,
145
+ SHORTCUT_TO_FILTER_OPERATOR: () => SHORTCUT_TO_FILTER_OPERATOR,
142
146
  UserProfileService: () => UserProfileService,
143
147
  UserService: () => UserService,
144
148
  ViewService: () => ViewService,
145
149
  buildAuditChanges: () => buildAuditChanges,
150
+ createDefaultState: () => createDefaultState,
146
151
  createMockAdapter: () => createMockAdapter,
152
+ createQueryBuilder: () => createQueryBuilder,
147
153
  enrichValuesWithSelectLabels: () => enrichValuesWithSelectLabels,
148
154
  evaluateFormula: () => evaluateFormula,
149
155
  evaluateFormulaAttribute: () => evaluateFormulaAttribute,
@@ -156,6 +162,8 @@ __export(runtime_exports, {
156
162
  extractRelationReferences: () => extractRelationReferences,
157
163
  flattenRelationsForEval: () => flattenRelationsForEval,
158
164
  formatFormulaResult: () => formatFormulaResult,
165
+ formatRecord: () => formatRecord,
166
+ formatRecords: () => formatRecords,
159
167
  getPathDepth: () => getPathDepth,
160
168
  getRelationPath: () => getRelationPath,
161
169
  getSyncPreview: () => getSyncPreview,
@@ -179,6 +187,477 @@ __export(runtime_exports, {
179
187
  });
180
188
  module.exports = __toCommonJS(runtime_exports);
181
189
 
190
+ // src/runtime/client/types.ts
191
+ function formatRecord(record) {
192
+ return {
193
+ id: record.id,
194
+ createdAt: record.createdAt,
195
+ updatedAt: record.updatedAt,
196
+ metadata: record.metadata ?? {},
197
+ ...record.values
198
+ };
199
+ }
200
+ function formatRecords(records) {
201
+ return records.map((r) => formatRecord(r));
202
+ }
203
+ function createDefaultState(objectName) {
204
+ return {
205
+ objectName,
206
+ filters: [],
207
+ combinator: "and",
208
+ sorts: [],
209
+ raw: false,
210
+ includeDeleted: false
211
+ };
212
+ }
213
+ var SHORTCUT_TO_FILTER_OPERATOR = {
214
+ eq: "is",
215
+ neq: "is_not",
216
+ gt: "gt",
217
+ gte: "gte",
218
+ lt: "lt",
219
+ lte: "lte",
220
+ contains: "contains",
221
+ notContains: "not_contains",
222
+ startsWith: "starts_with",
223
+ endsWith: "ends_with",
224
+ isEmpty: "is_empty",
225
+ isNotEmpty: "is_not_empty"
226
+ };
227
+ var QueryNoResultError = class extends Error {
228
+ constructor(objectName, filters) {
229
+ const filterInfo = filters?.length ? ` with filters: ${JSON.stringify(filters)}` : "";
230
+ super(`No record found in "${objectName}"${filterInfo}`);
231
+ this.name = "QueryNoResultError";
232
+ this.objectName = objectName;
233
+ this.filters = filters;
234
+ }
235
+ };
236
+ var QueryMultipleResultsError = class extends Error {
237
+ constructor(objectName, count) {
238
+ super(
239
+ `Expected single record in "${objectName}", but found ${count}. Use first() or add more filters.`
240
+ );
241
+ this.name = "QueryMultipleResultsError";
242
+ this.objectName = objectName;
243
+ this.count = count;
244
+ }
245
+ };
246
+
247
+ // src/runtime/client/query-builder.ts
248
+ var QueryBuilder = class _QueryBuilder {
249
+ constructor(recordService, adapter, objectName, state) {
250
+ this.objectId = null;
251
+ this.recordService = recordService;
252
+ this.adapter = adapter;
253
+ this.state = {
254
+ ...createDefaultState(objectName),
255
+ ...state
256
+ };
257
+ }
258
+ /**
259
+ * Clone the builder with new state (immutability pattern)
260
+ */
261
+ clone(updates) {
262
+ return new _QueryBuilder(this.recordService, this.adapter, this.state.objectName, {
263
+ ...this.state,
264
+ ...updates
265
+ });
266
+ }
267
+ /**
268
+ * Resolve object name to object ID (cached)
269
+ */
270
+ async resolveObjectId() {
271
+ if (this.objectId) return this.objectId;
272
+ const tenantId = this.recordService.tenantId;
273
+ const dbObject = await this.adapter.objects.findByName(tenantId, this.state.objectName);
274
+ if (!dbObject) {
275
+ throw new Error(`Object "${this.state.objectName}" not found`);
276
+ }
277
+ this.objectId = dbObject.id;
278
+ return dbObject.id;
279
+ }
280
+ // ============================================================================
281
+ // FILTER METHODS
282
+ // ============================================================================
283
+ /**
284
+ * Add a filter rule with explicit operator
285
+ *
286
+ * @example
287
+ * ```typescript
288
+ * qb.where('status', 'is', 'active')
289
+ * .where('price', 'gte', 100)
290
+ * ```
291
+ */
292
+ where(attribute, operator, value) {
293
+ const rule = { attribute, operator, value };
294
+ return this.clone({
295
+ filters: [...this.state.filters, rule]
296
+ });
297
+ }
298
+ /**
299
+ * Set filter combinator (default: 'and')
300
+ */
301
+ or() {
302
+ return this.clone({ combinator: "or" });
303
+ }
304
+ // ============================================================================
305
+ // SHORTCUT OPERATORS
306
+ // ============================================================================
307
+ /**
308
+ * Equal (is)
309
+ */
310
+ eq(attribute, value) {
311
+ return this.where(attribute, SHORTCUT_TO_FILTER_OPERATOR.eq, value);
312
+ }
313
+ /**
314
+ * Not equal (is_not)
315
+ */
316
+ neq(attribute, value) {
317
+ return this.where(attribute, SHORTCUT_TO_FILTER_OPERATOR.neq, value);
318
+ }
319
+ /**
320
+ * Greater than
321
+ */
322
+ gt(attribute, value) {
323
+ return this.where(attribute, SHORTCUT_TO_FILTER_OPERATOR.gt, value);
324
+ }
325
+ /**
326
+ * Greater than or equal
327
+ */
328
+ gte(attribute, value) {
329
+ return this.where(attribute, SHORTCUT_TO_FILTER_OPERATOR.gte, value);
330
+ }
331
+ /**
332
+ * Less than
333
+ */
334
+ lt(attribute, value) {
335
+ return this.where(attribute, SHORTCUT_TO_FILTER_OPERATOR.lt, value);
336
+ }
337
+ /**
338
+ * Less than or equal
339
+ */
340
+ lte(attribute, value) {
341
+ return this.where(attribute, SHORTCUT_TO_FILTER_OPERATOR.lte, value);
342
+ }
343
+ /**
344
+ * Contains (text/relation)
345
+ */
346
+ contains(attribute, value) {
347
+ return this.where(attribute, SHORTCUT_TO_FILTER_OPERATOR.contains, value);
348
+ }
349
+ /**
350
+ * Not contains
351
+ */
352
+ notContains(attribute, value) {
353
+ return this.where(attribute, SHORTCUT_TO_FILTER_OPERATOR.notContains, value);
354
+ }
355
+ /**
356
+ * Starts with (text)
357
+ */
358
+ startsWith(attribute, value) {
359
+ return this.where(attribute, SHORTCUT_TO_FILTER_OPERATOR.startsWith, value);
360
+ }
361
+ /**
362
+ * Ends with (text)
363
+ */
364
+ endsWith(attribute, value) {
365
+ return this.where(attribute, SHORTCUT_TO_FILTER_OPERATOR.endsWith, value);
366
+ }
367
+ /**
368
+ * Is empty
369
+ */
370
+ isEmpty(attribute) {
371
+ return this.where(attribute, SHORTCUT_TO_FILTER_OPERATOR.isEmpty, null);
372
+ }
373
+ /**
374
+ * Is not empty
375
+ */
376
+ isNotEmpty(attribute) {
377
+ return this.where(attribute, SHORTCUT_TO_FILTER_OPERATOR.isNotEmpty, null);
378
+ }
379
+ /**
380
+ * In list (any_of)
381
+ */
382
+ in(attribute, values) {
383
+ return this.where(attribute, "any_of", values);
384
+ }
385
+ /**
386
+ * Not in list (none_of)
387
+ */
388
+ notIn(attribute, values) {
389
+ return this.where(attribute, "none_of", values);
390
+ }
391
+ // ============================================================================
392
+ // SORT & PAGINATION
393
+ // ============================================================================
394
+ /**
395
+ * Add sort rule
396
+ */
397
+ orderBy(attribute, direction = "asc") {
398
+ return this.clone({
399
+ sorts: [...this.state.sorts, { attribute, direction }]
400
+ });
401
+ }
402
+ /**
403
+ * Set limit
404
+ */
405
+ limit(count) {
406
+ return this.clone({ limit: count });
407
+ }
408
+ /**
409
+ * Set offset
410
+ */
411
+ offset(count) {
412
+ return this.clone({ offset: count });
413
+ }
414
+ /**
415
+ * Include soft-deleted records
416
+ */
417
+ withDeleted() {
418
+ return this.clone({ includeDeleted: true });
419
+ }
420
+ // ============================================================================
421
+ // GROUP BY (KANBAN)
422
+ // ============================================================================
423
+ /**
424
+ * Group results by an attribute (for Kanban views).
425
+ * Only status and select attributes are supported.
426
+ *
427
+ * @example
428
+ * ```typescript
429
+ * const grouped = await qb
430
+ * .groupBy('status')
431
+ * .fetchGrouped();
432
+ * // → { groups: { 'pending': [...], 'active': [...] }, total: 50 }
433
+ * ```
434
+ */
435
+ groupBy(attribute) {
436
+ return this.clone({ groupBy: attribute });
437
+ }
438
+ // ============================================================================
439
+ // RAW MODE
440
+ // ============================================================================
441
+ /**
442
+ * Return raw ObjectRecord instead of formatted record.
443
+ * Includes all metadata (objectId, label, completionStatus, etc.)
444
+ */
445
+ raw() {
446
+ return this.clone({ raw: true });
447
+ }
448
+ // ============================================================================
449
+ // TERMINATION METHODS (READ)
450
+ // ============================================================================
451
+ /**
452
+ * Fetch records with current filters
453
+ * Returns formatted records by default, raw ObjectRecords if raw() was called
454
+ */
455
+ async fetch() {
456
+ const objectId = await this.resolveObjectId();
457
+ const filters = this.state.filters.length > 0 ? { combinator: this.state.combinator, rules: this.state.filters } : void 0;
458
+ const result = await this.recordService.listRecords(objectId, {
459
+ filters,
460
+ sorts: this.state.sorts.length > 0 ? this.state.sorts : void 0,
461
+ limit: this.state.limit,
462
+ offset: this.state.offset,
463
+ includeDeleted: this.state.includeDeleted
464
+ });
465
+ if (this.state.raw) {
466
+ return result;
467
+ }
468
+ return {
469
+ records: formatRecords(result.records),
470
+ total: result.total
471
+ };
472
+ }
473
+ /**
474
+ * Get a single record (throws if 0 or >1 results)
475
+ */
476
+ async single() {
477
+ const result = await this.limit(2).fetch();
478
+ if (result.records.length === 0) {
479
+ throw new QueryNoResultError(this.state.objectName, this.state.filters);
480
+ }
481
+ if (result.records.length > 1) {
482
+ throw new QueryMultipleResultsError(this.state.objectName, result.total);
483
+ }
484
+ return result.records[0];
485
+ }
486
+ /**
487
+ * Get first record or null
488
+ */
489
+ async first() {
490
+ const result = await this.limit(1).fetch();
491
+ return result.records[0] ?? null;
492
+ }
493
+ /**
494
+ * Get record by ID
495
+ */
496
+ async findById(id) {
497
+ const record = await this.recordService.getRecord(id);
498
+ if (!record) return null;
499
+ if (this.state.raw) {
500
+ return record;
501
+ }
502
+ return formatRecord(record);
503
+ }
504
+ /**
505
+ * Count records matching filters
506
+ */
507
+ async count() {
508
+ const result = await this.limit(0).fetch();
509
+ return result.total;
510
+ }
511
+ /**
512
+ * Fetch records grouped by the groupBy attribute (for Kanban views).
513
+ * Requires groupBy() to be called first.
514
+ *
515
+ * @example
516
+ * ```typescript
517
+ * const result = await schema.from('tasks')
518
+ * .groupBy('status')
519
+ * .fetchGrouped();
520
+ * // → { groups: { 'todo': [...], 'in_progress': [...], 'done': [...] }, total: 50 }
521
+ * ```
522
+ */
523
+ async fetchGrouped() {
524
+ if (!this.state.groupBy) {
525
+ throw new Error("groupBy() must be called before fetchGrouped()");
526
+ }
527
+ const result = await this.fetch();
528
+ const groupByAttr = this.state.groupBy;
529
+ const groups = {};
530
+ for (const record of result.records) {
531
+ const value = record[groupByAttr];
532
+ const key = value != null ? String(value) : "__null__";
533
+ if (!groups[key]) {
534
+ groups[key] = [];
535
+ }
536
+ groups[key].push(record);
537
+ }
538
+ return {
539
+ groups,
540
+ total: result.total
541
+ };
542
+ }
543
+ // ============================================================================
544
+ // TERMINATION METHODS (WRITE)
545
+ // ============================================================================
546
+ /**
547
+ * Create a new record
548
+ * System fields (id, createdAt, updatedAt) are managed automatically.
549
+ *
550
+ * @example
551
+ * ```typescript
552
+ * const product = await schema.from('products')
553
+ * .insert({ name: 'iPhone', price: 999 });
554
+ *
555
+ * // Allow draft (missing required fields)
556
+ * const draft = await schema.from('products')
557
+ * .insert({ name: 'Draft' }, { allowDraft: true });
558
+ *
559
+ * // With custom metadata
560
+ * const withMeta = await schema.from('products')
561
+ * .insert({ name: 'iPhone' }, { metadata: { externalId: 'ext-123' } });
562
+ * ```
563
+ */
564
+ async insert(data, options) {
565
+ const objectId = await this.resolveObjectId();
566
+ const record = await this.recordService.createRecord(
567
+ objectId,
568
+ data,
569
+ {
570
+ allowDraft: options?.allowDraft,
571
+ validate: options?.validate,
572
+ metadata: options?.metadata
573
+ }
574
+ );
575
+ if (this.state.raw) {
576
+ return record;
577
+ }
578
+ return formatRecord(record);
579
+ }
580
+ /**
581
+ * Update records matching filters (expects exactly 1 record by default)
582
+ * Use with eq('id', ...) for single record update.
583
+ * System fields (id, createdAt, updatedAt) are managed automatically.
584
+ *
585
+ * @example
586
+ * ```typescript
587
+ * const updated = await schema.from('products')
588
+ * .eq('id', 'rec-123')
589
+ * .update({ price: 899 });
590
+ *
591
+ * // Update with metadata (replaces existing metadata)
592
+ * const withMeta = await schema.from('products')
593
+ * .eq('id', 'rec-123')
594
+ * .update({ price: 899 }, { metadata: { synced: true } });
595
+ * ```
596
+ */
597
+ async update(data, options) {
598
+ const existing = await this.single();
599
+ const recordId = this.state.raw ? existing.id : existing.id;
600
+ const record = await this.recordService.updateRecord(
601
+ recordId,
602
+ data,
603
+ {
604
+ partial: true,
605
+ metadata: options?.metadata
606
+ }
607
+ );
608
+ if (this.state.raw) {
609
+ return record;
610
+ }
611
+ return formatRecord(record);
612
+ }
613
+ /**
614
+ * Delete record matching filters (expects exactly 1 record)
615
+ *
616
+ * @example
617
+ * ```typescript
618
+ * await schema.from('products')
619
+ * .eq('id', 'rec-123')
620
+ * .delete();
621
+ * ```
622
+ */
623
+ async delete() {
624
+ const existing = await this.single();
625
+ const recordId = this.state.raw ? existing.id : existing.id;
626
+ await this.recordService.deleteRecord(recordId);
627
+ }
628
+ /**
629
+ * Upsert (update if exists by ID, insert otherwise)
630
+ * Provide 'id' for lookup. System fields (createdAt, updatedAt) are managed automatically.
631
+ *
632
+ * @example
633
+ * ```typescript
634
+ * // Insert if not exists, update if exists
635
+ * const product = await schema.from('products')
636
+ * .upsert({ id: 'rec-123', name: 'iPhone', price: 999 });
637
+ *
638
+ * // With metadata
639
+ * const withMeta = await schema.from('products')
640
+ * .upsert({ name: 'iPhone' }, { metadata: { source: 'import' } });
641
+ * ```
642
+ */
643
+ async upsert(data, options) {
644
+ const { id, ...rest } = data;
645
+ const writeData = rest;
646
+ if (id) {
647
+ const existing = await this.findById(id);
648
+ if (existing) {
649
+ return this.eq("id", id).update(writeData, {
650
+ metadata: options?.metadata
651
+ });
652
+ }
653
+ }
654
+ return this.insert(writeData, options);
655
+ }
656
+ };
657
+ function createQueryBuilder(recordService, adapter, objectName) {
658
+ return new QueryBuilder(recordService, adapter, objectName);
659
+ }
660
+
182
661
  // src/runtime/formula/evaluator.ts
183
662
  var import_expr_eval = require("expr-eval");
184
663
  function createFormulaParser() {
@@ -1201,6 +1680,7 @@ function createMockObjectRecordsRepository(stores) {
1201
1680
  label: data.label,
1202
1681
  completionStatus: data.completionStatus,
1203
1682
  values: data.data,
1683
+ metadata: data.metadata ?? {},
1204
1684
  createdAt: /* @__PURE__ */ new Date(),
1205
1685
  updatedAt: /* @__PURE__ */ new Date()
1206
1686
  };
@@ -1213,11 +1693,13 @@ function createMockObjectRecordsRepository(stores) {
1213
1693
  if (!existing) {
1214
1694
  return Promise.reject(new Error(`ObjectRecord ${id} not found`));
1215
1695
  }
1216
- const { __completionStatus, __label, ...valueData } = data;
1696
+ const { __completionStatus, __label, __metadata, ...valueData } = data;
1217
1697
  const updated = {
1218
1698
  ...existing,
1219
1699
  label: __label ?? existing.label,
1220
1700
  completionStatus: __completionStatus ?? existing.completionStatus,
1701
+ // Metadata uses replace behavior (not merge)
1702
+ metadata: __metadata !== void 0 ? __metadata : existing.metadata,
1221
1703
  values: { ...existing.values, ...valueData },
1222
1704
  updatedAt: /* @__PURE__ */ new Date()
1223
1705
  };
@@ -3415,7 +3897,7 @@ function createCheckboxValidator(_attr) {
3415
3897
  return import_zod4.z.boolean();
3416
3898
  }
3417
3899
  function createDateValidator(attr) {
3418
- return import_zod4.z.string().datetime({ message: `${attr.label} must be a valid ISO date` });
3900
+ return import_zod4.z.coerce.date({ message: `${attr.label} must be a valid ISO date` });
3419
3901
  }
3420
3902
  function createPhoneValidator(_attr) {
3421
3903
  return import_zod4.z.object({
@@ -3521,7 +4003,7 @@ function createRelationValidator(attr) {
3521
4003
  return createSingleRelationValidator(attr);
3522
4004
  }
3523
4005
  function createRatingValidator(attr) {
3524
- let schema = import_zod4.z.number().int().min(0);
4006
+ let schema = import_zod4.z.number().min(0);
3525
4007
  if (attr.max !== void 0) {
3526
4008
  schema = schema.max(attr.max, `${attr.label} must be at most ${attr.max}`);
3527
4009
  }
@@ -3675,7 +4157,7 @@ function computeRecordStatus(objectDef, data) {
3675
4157
  }
3676
4158
 
3677
4159
  // src/runtime/services/object-schema.service.ts
3678
- var ObjectSchemaService = class {
4160
+ var _ObjectSchemaService = class _ObjectSchemaService {
3679
4161
  constructor(adapter, nativeRegistry, options) {
3680
4162
  this.adapter = adapter;
3681
4163
  this.nativeRegistry = nativeRegistry;
@@ -4098,6 +4580,14 @@ var ObjectSchemaService = class {
4098
4580
  "Invalid attribute name format.\nName must be a valid variable identifier:\n \u2705 Valid: 'firstName', 'first_name', 'FirstName', 'FIRST_NAME'\n \u274C Invalid: 'first-name', 'first name', '123name', 'first.name'"
4099
4581
  );
4100
4582
  }
4583
+ if (_ObjectSchemaService.RESERVED_ATTRIBUTE_NAMES.includes(
4584
+ name
4585
+ )) {
4586
+ throw new Error(
4587
+ `"${name}" is a reserved name and cannot be used as an attribute name.
4588
+ Reserved names: ${_ObjectSchemaService.RESERVED_ATTRIBUTE_NAMES.join(", ")}`
4589
+ );
4590
+ }
4101
4591
  }
4102
4592
  /**
4103
4593
  * Get complete object schema (system + custom attributes)
@@ -4344,6 +4834,17 @@ var ObjectSchemaService = class {
4344
4834
  return baseAttr;
4345
4835
  }
4346
4836
  };
4837
+ /**
4838
+ * Reserved attribute names that cannot be used.
4839
+ * These names conflict with system fields on ObjectRecord.
4840
+ */
4841
+ _ObjectSchemaService.RESERVED_ATTRIBUTE_NAMES = [
4842
+ "id",
4843
+ "createdAt",
4844
+ "updatedAt",
4845
+ "metadata"
4846
+ ];
4847
+ var ObjectSchemaService = _ObjectSchemaService;
4347
4848
 
4348
4849
  // src/runtime/services/permission.service.ts
4349
4850
  var PermissionService = class {
@@ -5451,6 +5952,71 @@ var RollupService = class {
5451
5952
  }
5452
5953
  return [...new Set(affectedIds)];
5453
5954
  }
5955
+ /**
5956
+ * Find records that have forward rollups pointing to the modified record.
5957
+ *
5958
+ * Forward rollups are rollups where the record has a relation attribute
5959
+ * pointing to another object, and the rollup aggregates values from that target.
5960
+ * When the target record changes, we need to recalculate these rollups.
5961
+ *
5962
+ * Example: Order has relation "company" → Company, and rollup "capitalSocial"
5963
+ * aggregating from the Company. When Company.capitalSocial changes,
5964
+ * all Orders pointing to that Company need their rollup recalculated.
5965
+ *
5966
+ * @param changedRecord - The record that was modified
5967
+ * @param changedSchema - Schema of the changed record's object
5968
+ * @returns Array of records that need their forward rollups recalculated
5969
+ */
5970
+ async findRecordsWithForwardRollup(changedRecord, changedSchema) {
5971
+ const affectedRecords = [];
5972
+ const recordObject = await this.adapter.objects.findById(changedRecord.objectId);
5973
+ if (!recordObject) {
5974
+ return affectedRecords;
5975
+ }
5976
+ const tenantId = recordObject.tenantId;
5977
+ const allObjects = await this.adapter.objects.list(tenantId);
5978
+ for (const obj of allObjects) {
5979
+ if (obj.id === changedRecord.objectId) {
5980
+ continue;
5981
+ }
5982
+ const attributes = await this.adapter.attributes.findByObjectId(obj.id);
5983
+ const rollupAttrs = attributes.filter((a) => a.type === "rollup");
5984
+ if (rollupAttrs.length === 0) {
5985
+ continue;
5986
+ }
5987
+ for (const rollupDbAttr of rollupAttrs) {
5988
+ const rollupConfig = rollupDbAttr.config;
5989
+ if (!rollupConfig?.relationAttribute) {
5990
+ continue;
5991
+ }
5992
+ const relationAttr = attributes.find(
5993
+ (a) => a.type === "relation" && a.name === rollupConfig.relationAttribute
5994
+ );
5995
+ if (!relationAttr) {
5996
+ continue;
5997
+ }
5998
+ const relationConfig = relationAttr.config;
5999
+ const targetsChangedObject = relationConfig?.targets?.some(
6000
+ (t) => t.object === changedSchema.name
6001
+ );
6002
+ if (!targetsChangedObject) {
6003
+ continue;
6004
+ }
6005
+ const recordsPointingToChanged = await this.adapter.objectRecords.findByRelation(
6006
+ tenantId,
6007
+ obj.id,
6008
+ relationAttr.name,
6009
+ changedRecord.id
6010
+ );
6011
+ affectedRecords.push(...recordsPointingToChanged);
6012
+ }
6013
+ }
6014
+ const uniqueRecords = /* @__PURE__ */ new Map();
6015
+ for (const record of affectedRecords) {
6016
+ uniqueRecords.set(record.id, record);
6017
+ }
6018
+ return Array.from(uniqueRecords.values());
6019
+ }
5454
6020
  };
5455
6021
 
5456
6022
  // src/runtime/services/user.service.ts
@@ -5575,7 +6141,11 @@ var RecordService = class {
5575
6141
  constructor(adapter, tenantId, options) {
5576
6142
  this.adapter = adapter;
5577
6143
  this.tenantId = tenantId;
5578
- this.schemaService = new ObjectSchemaService(adapter, registry);
6144
+ this.schemaService = new ObjectSchemaService(adapter, registry, {
6145
+ userId: options?.userId,
6146
+ userEmail: options?.userEmail,
6147
+ auditService: options?.auditService
6148
+ });
5579
6149
  this.relationService = new RelationService(adapter, registry);
5580
6150
  this.userService = new UserService(adapter, tenantId);
5581
6151
  this.rollupService = new RollupService(adapter);
@@ -5730,7 +6300,8 @@ var RecordService = class {
5730
6300
  objectId,
5731
6301
  data,
5732
6302
  label,
5733
- completionStatus
6303
+ completionStatus,
6304
+ metadata: options?.metadata
5734
6305
  });
5735
6306
  if (!options?.skipHooks) {
5736
6307
  const afterCtx = {
@@ -5844,11 +6415,15 @@ var RecordService = class {
5844
6415
  }
5845
6416
  const completionStatus = computeRecordStatus(schema, mergedData);
5846
6417
  const label = await this.computeLabel(schema, mergedData);
5847
- const updated = await this.adapter.objectRecords.update(recordId, {
6418
+ const updatePayload = {
5848
6419
  ...data,
5849
6420
  __completionStatus: completionStatus,
5850
6421
  __label: label
5851
- });
6422
+ };
6423
+ if (options?.metadata !== void 0) {
6424
+ updatePayload.__metadata = options.metadata;
6425
+ }
6426
+ const updated = await this.adapter.objectRecords.update(recordId, updatePayload);
5852
6427
  if (!options?.skipHooks) {
5853
6428
  const afterCtx = {
5854
6429
  ...hookCtx,
@@ -6117,9 +6692,10 @@ var RecordService = class {
6117
6692
  /**
6118
6693
  * Recalculate rollups after a record changes
6119
6694
  *
6120
- * This handles two cases:
6695
+ * This handles three cases:
6121
6696
  * 1. The record itself has rollups (e.g., aggregating from related records it points to)
6122
- * 2. Parent records have rollups that aggregate from this record
6697
+ * 2. Parent records have rollups that aggregate from this record (reverse pattern)
6698
+ * 3. Records that have forward rollups pointing to this record (forward pattern)
6123
6699
  *
6124
6700
  * @param record - The record that was modified
6125
6701
  * @param schema - Schema of the record's object
@@ -6133,19 +6709,26 @@ var RecordService = class {
6133
6709
  await this.rollupService.recalculateAndUpdate(record, schema);
6134
6710
  }
6135
6711
  const affectedParentIds = await this.rollupService.findAffectedParentRecords(record, schema);
6136
- if (affectedParentIds.length === 0) {
6137
- return;
6138
- }
6139
- const parentRecords = await this.adapter.objectRecords.findByIds(affectedParentIds);
6140
- for (const parentRecord of parentRecords) {
6141
- const parentSchema = await this.schemaService.getObjectSchema(parentRecord.objectId);
6142
- const rollupAttrs = parentSchema.attributes.filter(
6143
- (a) => a.type === "rollup"
6144
- );
6145
- if (rollupAttrs.length > 0) {
6146
- await this.rollupService.recalculateAndUpdate(parentRecord, parentSchema);
6712
+ if (affectedParentIds.length > 0) {
6713
+ const parentRecords = await this.adapter.objectRecords.findByIds(affectedParentIds);
6714
+ for (const parentRecord of parentRecords) {
6715
+ const parentSchema = await this.schemaService.getObjectSchema(parentRecord.objectId);
6716
+ const rollupAttrs = parentSchema.attributes.filter(
6717
+ (a) => a.type === "rollup"
6718
+ );
6719
+ if (rollupAttrs.length > 0) {
6720
+ await this.rollupService.recalculateAndUpdate(parentRecord, parentSchema);
6721
+ }
6147
6722
  }
6148
6723
  }
6724
+ const affectedForwardRecords = await this.rollupService.findRecordsWithForwardRollup(
6725
+ record,
6726
+ schema
6727
+ );
6728
+ for (const forwardRecord of affectedForwardRecords) {
6729
+ const forwardSchema = await this.schemaService.getObjectSchema(forwardRecord.objectId);
6730
+ await this.rollupService.recalculateAndUpdate(forwardRecord, forwardSchema);
6731
+ }
6149
6732
  }
6150
6733
  /**
6151
6734
  * Permanently delete a record (hard delete)
@@ -7261,16 +7844,22 @@ var NoopGeocodingAdapter = class {
7261
7844
  NoopHookRegistry,
7262
7845
  ObjectSchemaService,
7263
7846
  PermissionService,
7847
+ QueryBuilder,
7848
+ QueryMultipleResultsError,
7849
+ QueryNoResultError,
7264
7850
  RecordService,
7265
7851
  RelationResolverService,
7266
7852
  RelationService,
7267
7853
  RollupScheduler,
7268
7854
  RollupService,
7855
+ SHORTCUT_TO_FILTER_OPERATOR,
7269
7856
  UserProfileService,
7270
7857
  UserService,
7271
7858
  ViewService,
7272
7859
  buildAuditChanges,
7860
+ createDefaultState,
7273
7861
  createMockAdapter,
7862
+ createQueryBuilder,
7274
7863
  enrichValuesWithSelectLabels,
7275
7864
  evaluateFormula,
7276
7865
  evaluateFormulaAttribute,
@@ -7283,6 +7872,8 @@ var NoopGeocodingAdapter = class {
7283
7872
  extractRelationReferences,
7284
7873
  flattenRelationsForEval,
7285
7874
  formatFormulaResult,
7875
+ formatRecord,
7876
+ formatRecords,
7286
7877
  getPathDepth,
7287
7878
  getRelationPath,
7288
7879
  getSyncPreview,