@@ -315,6 +315,16 @@ import { assertProjectionNamesAField, refuseProjectionOfUnknownColumns } from '.
315315import { assertAggregationFieldTypesAccepted } from './aggregate-field-type-door.js';
316316import { assertRecordCurrencyAggregatesGrouped } from './record-currency-aggregate-door.js';
317317import { assertRecordCurrencyCodes, touchesRecordCurrency } from './validation/record-currency-code.js';
318+ import {
319+ EXCHANGE_RATE_OBJECT,
320+ homeAmountsTouched,
321+ memoizeHomeAmountServing,
322+ planHomeAmounts,
323+ projectHomeAmounts,
324+ settleBulkHomeAmounts,
325+ type HomeAmountPlanEntry,
326+ type HomeAmountServing,
327+ } from './home-amount-projection.js';
318328import { assertAggregatedColumnsAreDistinct, assertDateBucketTargetsAreTemporal } from './aggregate-query-shape-door.js';
319329import { applyProtection, pluralToSingular, ExternalWriteForbiddenError } from '@objectstack/spec/shared';
320330import { SchemaRegistry, type ArtifactInstallScope } from './registry.js';
@@ -9654,6 +9664,134 @@ export class ObjectQL implements IObjectQLEngine {
96549664 this.logger.info('I18nService configured for validation messages');
96559665 }
96569666
9667+ /**
9668+ * [ADR-0144 D4, Open item 4] Where the write-time home projection reads an
9669+ * organization's home currency: its `localization.currency` setting, or
9670+ * `undefined` when unset. Bridged by `ObjectQLPlugin`, which resolves the
9671+ * settings service when a write asks, not when it starts. With no resolver
9672+ * every organization's home currency is unset, so a write of an amount
9673+ * declaring `homeAmount` is refused `ERR_HOME_CURRENCY_UNSET` — the engine
9674+ * never assumes one.
9675+ */
9676+ setHomeCurrencyResolver(resolver: ((organizationId: string | undefined) => Promise<string | undefined>) | undefined): void {
9677+ this.homeCurrencyResolver = resolver;
9678+ }
9679+
9680+ private homeCurrencyResolver?: (organizationId: string | undefined) => Promise<string | undefined>;
9681+
9682+ /**
9683+ * [ADR-0144 D5, Open item 2] What one write's home projection reads, memoized
9684+ * for that write: each organization's home currency once, and each
9685+ * (organization, currency, document date) rate once, however many rows ask.
9686+ *
9687+ * The rate is the `sys_exchange_rate` row of that currency with the latest
9688+ * `effective_date` on or before the document date, in the organization the
9689+ * row lands in. Read `sudo()`-shaped (`{ ...context, isSystem: true }`): a
9690+ * rank-and-file writer cannot read the rate table (parameter 2 makes it an
9691+ * administrator's object), yet every write of theirs is converted through
9692+ * it; the caller's transaction rides along, and `tenantId` is the row's own
9693+ * organization, so one organization's rate never converts another's
9694+ * amount. An install without the rate object registered has no rate for any
9695+ * currency.
9696+ */
9697+ private homeAmountServing(context: ExecutionContext | undefined): HomeAmountServing {
9698+ return memoizeHomeAmountServing({
9699+ homeCurrency: async (organizationId) => this.homeCurrencyResolver?.(organizationId),
9700+ rateOn: async (organizationId, currency, onOrBefore) => {
9701+ if (!this._registry.getObject(EXCHANGE_RATE_OBJECT)) return undefined;
9702+ const rows = await this.find(EXCHANGE_RATE_OBJECT, {
9703+ where: { currency, effective_date: { $lte: onOrBefore } },
9704+ orderBy: [{ field: 'effective_date', order: 'desc' }],
9705+ limit: 1,
9706+ fields: ['rate', 'effective_date'],
9707+ context: {
9708+ ...(context ?? {}),
9709+ isSystem: true,
9710+ ...(organizationId !== undefined ? { tenantId: organizationId } : {}),
9711+ } as ExecutionContext,
9712+ });
9713+ const row = rows[0] as { rate?: unknown; effective_date?: unknown } | undefined;
9714+ if (!row) return undefined;
9715+ const effective = row.effective_date;
9716+ return {
9717+ rate: row.rate,
9718+ effectiveDate: effective instanceof Date ? effective.toISOString().slice(0, 10) : (typeof effective === 'string' ? effective : undefined),
9719+ };
9720+ },
9721+ });
9722+ }
9723+
9724+ /**
9725+ * [ADR-0144 D5] The organization a written row belongs to, for its home
9726+ * projection: the row's own `organization_id`, else the writer's active
9727+ * organization, else the organization a system insert resolved — the
9728+ * assignment seam's reading, so both seams convert and route one row under
9729+ * one organization.
9730+ */
9731+ private static homeAmountOrganizationOf(
9732+ row: Record<string, unknown> | null | undefined,
9733+ context: ExecutionContext | undefined,
9734+ derived?: string | undefined,
9735+ ): string | undefined {
9736+ const own = row?.organization_id;
9737+ if (own !== undefined && own !== null && own !== '') return String(own);
9738+ if (context?.tenantId) return String(context.tenantId);
9739+ return derived ?? undefined;
9740+ }
9741+
9742+ /**
9743+ * [ADR-0144 D5] The `validate()` preview's run of the home projection: the
9744+ * write's own computation over the preview's image (the insert row, or the
9745+ * stored row with the patch over it), answering nothing and writing
9746+ * nothing — it throws exactly the refusal the write would.
9747+ */
9748+ private async previewHomeAmounts(
9749+ object: string,
9750+ schema: unknown,
9751+ mode: 'insert' | 'update',
9752+ row: Record<string, unknown>,
9753+ image: Record<string, unknown>,
9754+ context: ExecutionContext | undefined,
9755+ recordId: unknown,
9756+ ): Promise<void> {
9757+ const plan = planHomeAmounts(schema);
9758+ const entries = mode === 'insert' ? plan : homeAmountsTouched(plan, row);
9759+ if (entries.length === 0) return;
9760+ await projectHomeAmounts(entries, image, this.homeAmountServing(context), {
9761+ object,
9762+ operation: mode,
9763+ organizationId: ObjectQL.homeAmountOrganizationOf(image, context),
9764+ recordId,
9765+ });
9766+ }
9767+
9768+ /**
9769+ * [ADR-0144 D5] Project a by-id update's home amounts onto its FINAL patch:
9770+ * only the declarations the patch touches ({@link homeAmountsTouched}),
9771+ * computed over the stored row with the patch laid over it. Shared by the
9772+ * write and its `validate()` preview, so a dry run refuses what the write
9773+ * refuses.
9774+ */
9775+ private async projectHomeAmountsOntoPatch(
9776+ object: string,
9777+ plan: readonly HomeAmountPlanEntry[],
9778+ prior: Record<string, unknown> | null | undefined,
9779+ patch: Record<string, unknown>,
9780+ context: ExecutionContext | undefined,
9781+ recordId: unknown,
9782+ serving: HomeAmountServing = this.homeAmountServing(context),
9783+ ): Promise<void> {
9784+ const touched = homeAmountsTouched(plan, patch);
9785+ if (touched.length === 0) return;
9786+ const image = { ...(prior ?? {}), ...patch };
9787+ Object.assign(patch, await projectHomeAmounts(touched, image, serving, {
9788+ object,
9789+ operation: 'update',
9790+ organizationId: ObjectQL.homeAmountOrganizationOf(image, context),
9791+ recordId,
9792+ }));
9793+ }
9794+
96579795 /**
96589796 * Locale + translation hooks handed to the validators so a rejected write is
96599797 * reported in the caller's language (#3957).
@@ -14530,6 +14668,7 @@ export class ObjectQL implements IObjectQLEngine {
1453014668 resolveOptionPermissions: (...a) => this.resolveOptionPermissions(...a),
1453114669 getSummaryDescriptors: (...a) => this.getSummaryDescriptors(...a),
1453214670 assertReferencesResolve: (...a) => this.assertReferencesResolve(...a),
14671+ previewHomeAmounts: (...a) => this.previewHomeAmounts(...a),
1453314672 };
1453414673 return validate.call(host, object, data, options);
1453514674 }
@@ -15585,6 +15724,35 @@ export class ObjectQL implements IObjectQLEngine {
1558515724 }
1558615725 }
1558715726 }
15727+ // [ADR-0144 D5] The write-time home projection — after every hook,
15728+ // strip and re-default, so the values it writes are the ones stored
15729+ // (a `readonly` strip never takes them, and a `defaultValue` on the
15730+ // home or rate field never stands), and before validation, which then
15731+ // judges them like any other stored value (the rate field's `scale`
15732+ // included). In every context: a system writer's value for either
15733+ // field is recomputed over too. A row it refuses (no home currency, no
15734+ // rate in force on the document date) is culled per row under partial
15735+ // mode, and nothing is written for it. Free for an object declaring no
15736+ // `homeAmount` (`planHomeAmounts` is one loop over the field names).
15737+ const homeAmountPlan = planHomeAmounts(schemaForValidation);
15738+ if (homeAmountPlan.length > 0) {
15739+ const homeServing = this.homeAmountServing(opCtx.context);
15740+ for (let i = 0; i < rows.length; i++) {
15741+ if (rowErrors[i] !== undefined) continue;
15742+ const row = rows[i] as Record<string, unknown> | null | undefined;
15743+ if (!row || typeof row !== 'object') continue;
15744+ try {
15745+ Object.assign(row, await projectHomeAmounts(homeAmountPlan, row, homeServing, {
15746+ object,
15747+ operation: 'insert',
15748+ organizationId: ObjectQL.homeAmountOrganizationOf(row, opCtx.context, derivedOrganizationId),
15749+ }));
15750+ } catch (e) {
15751+ if (!partialMode) throw e;
15752+ rowErrors[i] = e;
15753+ }
15754+ }
15755+ }
1558815756 // [#4977] A `parent`-scoped `requiredWhen` ("once the header is Sent,
1558915757 // every line must carry a description") is a SERVER guarantee, and the
1559015758 // insert is where a line is first written empty — so unlike the
@@ -17361,6 +17529,25 @@ export class ObjectQL implements IObjectQLEngine {
1736117529 applyStoredExpressionsToPatch(patch, computed!, priorRecord);
1736217530 }
1736317531 assertNoStrictDrops();
17532+ // [ADR-0144 D5] The write-time home projection on the by-id
17533+ // path, over the stored row with the FINAL payload laid over it
17534+ // — after the hooks, both strips and the strict refusal, so its
17535+ // values are never stripped and a strict caller is told about
17536+ // its own payload first — and before the second validation pass,
17537+ // which judges what it wrote. Only when the payload touches an
17538+ // amount declaring `homeAmount`, its code, its document date or
17539+ // one of the two projected fields: an unrelated edit never
17540+ // re-reads a rate. A value a system or `preserveAudit` writer
17541+ // sent for the home or rate field is recomputed over.
17542+ {
17543+ const homeAmountPlan = planHomeAmounts(updateSchema);
17544+ if (homeAmountPlan.length > 0) {
17545+ await this.projectHomeAmountsOntoPatch(
17546+ object, homeAmountPlan, priorRecord, hookContext.input.data as Record<string, unknown>,
17547+ opCtx.context, hookContext.input.id,
17548+ );
17549+ }
17550+ }
1736417551 // [#21663] The payload is FINAL here (see the seam below), so
1736517552 // every readonly value on it is one the driver will store: a
1736617553 // system writer's (the strip above never ran for it), a hook's,
@@ -17700,6 +17887,35 @@ export class ObjectQL implements IObjectQLEngine {
1770017887 }
1770117888 }
1770217889 assertNoStrictDrops();
17890+ // [ADR-0144 D5] The write-time home projection on the predicate
17891+ // path, at the by-id branch's point and for its reasons. One
17892+ // patch reaches every matched row, so each matched row (the D7
17893+ // read — `readPriorRows` is a memo) is projected with the patch
17894+ // laid over it, and {@link settleBulkHomeAmounts} keeps a
17895+ // projected value only when it is the same on every row; a patch
17896+ // that would convert to different values is refused before the
17897+ // statement. A refusal of any matched row (no home currency, no
17898+ // rate on its document date) refuses the whole update.
17899+ {
17900+ const homeAmountPlan = planHomeAmounts(updateSchema);
17901+ const homePatch = hookContext.input.data as Record<string, unknown>;
17902+ const touched = homeAmountsTouched(homeAmountPlan, homePatch);
17903+ if (touched.length > 0) {
17904+ const matched = ((await readPriorRows()) ?? []).filter((row) => row != null && typeof row === 'object');
17905+ const homeServing = this.homeAmountServing(opCtx.context);
17906+ const projected: Array<Record<string, unknown>> = [];
17907+ for (const row of matched) {
17908+ const image = { ...row, ...homePatch };
17909+ projected.push(await projectHomeAmounts(touched, image, homeServing, {
17910+ object,
17911+ operation: 'update',
17912+ organizationId: ObjectQL.homeAmountOrganizationOf(image, opCtx.context),
17913+ recordId: row.id,
17914+ }));
17915+ }
17916+ hookContext.input.data = settleBulkHomeAmounts(touched, homePatch, projected, object) as any;
17917+ }
17918+ }
1770317919 // [#21663] The predicate-path twin of the by-id second pass, at the
1770417920 // same point and for the same reason: the readonly values left on
1770517921 // the final payload are stored, so their SHAPE is judged — before
0 commit comments