Skip to content

Commit 86544d4

Browse files
committed
wip(objectql): wire the home projection into insert, by-id and predicate update, validate() preview; bridge localization.currency (#27192)
Claude-Session: https://claude.ai/code/session_01Utp6CYjLJ7sxfUehRrLbT3 Co-authored-by: Claude <noreply@anthropic.com>
1 parent b71e44a commit 86544d4

4 files changed

Lines changed: 294 additions & 2 deletions

File tree

‎packages/objectql/src/engine-validate.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import { validateRecordInScope, normalizeBlankTypedValues, normalizeNumericStrin
5151
import { evaluateValidationRules, hasParentScopedRequiredWhen, stripReadonlyFields, stripRuntimeOwnedFields } from './validation/rule-validator.js';
5252
import { createBatchSummaryAccumulator } from './summary-batch-accumulation.js';
5353
import { assertRecordCurrencyCodes, RecordCurrencyCodeRequiredError } from './validation/record-currency-code.js';
54+
import { isHomeAmountRefusal } from './home-amount-projection.js';
5455
import { resolveEngineUpdateDispatch, type EngineUpdateDispatchData } from './engine-update-dispatch.js';
5556
import {
5657
droppedFieldEvents,
@@ -93,11 +94,12 @@ export interface ValidateHost {
9394
resolveOptionPermissions: ObjectQL['resolveOptionPermissions'];
9495
getSummaryDescriptors: ObjectQL['getSummaryDescriptors'];
9596
assertReferencesResolve: ObjectQL['assertReferencesResolve'];
97+
previewHomeAmounts: ObjectQL['previewHomeAmounts'];
9698
}
9799

98100
/** The body of `ObjectQL#validate`; the method's docblock there is its contract. */
99101
export async function validate(
100-
this: Pick<ValidateHost, 'resolveObjectName' | '_registry' | 'permissionResolution' | 'resolveDefaultPermissions' | 'initializeSummaryFields' | 'applyFieldDefaults' | '_writeWallVerdict' | 'staticReadonlyCreateStrip' | 'logger' | 'mediaValueShapeStrictFor' | 'valueShapeStrictFor' | 'validationMessageContext' | 'buildEvalUser' | 'findOne' | 'readUpdatePriorRow' | 'resolvePreviewParents' | '_writeGateProbe' | 'resolvePredicateRelated' | 'resolveOptionPermissions' | 'getSummaryDescriptors' | 'assertReferencesResolve'>,
102+
this: Pick<ValidateHost, 'resolveObjectName' | '_registry' | 'permissionResolution' | 'resolveDefaultPermissions' | 'initializeSummaryFields' | 'applyFieldDefaults' | '_writeWallVerdict' | 'staticReadonlyCreateStrip' | 'logger' | 'mediaValueShapeStrictFor' | 'valueShapeStrictFor' | 'validationMessageContext' | 'buildEvalUser' | 'findOne' | 'readUpdatePriorRow' | 'resolvePreviewParents' | '_writeGateProbe' | 'resolvePredicateRelated' | 'resolveOptionPermissions' | 'getSummaryDescriptors' | 'assertReferencesResolve' | 'previewHomeAmounts'>,
101103
object: string,
102104
data: Record<string, unknown> | Record<string, unknown>[],
103105
options?: {
@@ -563,6 +565,14 @@ export async function validate(
563565
object, (schemaForValidation as { fields?: unknown } | undefined)?.fields,
564566
mode === 'insert' ? row : judgedViews[i], mode, mode === 'insert' ? undefined : row, storedIds[i],
565567
);
568+
// [ADR-0144 D5] The write's home projection, on the same image and
569+
// under the same condition: a row the write refuses for no home
570+
// currency or no rate on its document date (or a blank document date)
571+
// is refused here with the same code, so a dry run does not pass it.
572+
// It reads the rate table and the home currency; it writes nothing.
573+
await this.previewHomeAmounts(
574+
object, schemaForValidation, mode, row, mode === 'insert' ? row : judgedViews[i], options?.context, storedIds[i],
575+
);
566576
}
567577
warnings.push(...evaluateValidationRules(schemaForValidation as any, row, mode, {
568578
// [#22445] The stored row, when this row named one: the by-id
@@ -593,7 +603,7 @@ export async function validate(
593603
return { valid: false, errors: e.fields.map((f) => ({ ...f })), warnings };
594604
}
595605
// The write throws this refusal whole; the preview answers it as the row's finding.
596-
if (e instanceof RecordCurrencyCodeRequiredError) {
606+
if (e instanceof RecordCurrencyCodeRequiredError || isHomeAmountRefusal(e)) {
597607
return { valid: false, errors: [{ field: e.field, code: e.code, message: e.message }], warnings };
598608
}
599609
throw e;

‎packages/objectql/src/engine.ts‎

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,16 @@ import { assertProjectionNamesAField, refuseProjectionOfUnknownColumns } from '.
315315
import { assertAggregationFieldTypesAccepted } from './aggregate-field-type-door.js';
316316
import { assertRecordCurrencyAggregatesGrouped } from './record-currency-aggregate-door.js';
317317
import { 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';
318328
import { assertAggregatedColumnsAreDistinct, assertDateBucketTargetsAreTemporal } from './aggregate-query-shape-door.js';
319329
import { applyProtection, pluralToSingular, ExternalWriteForbiddenError } from '@objectstack/spec/shared';
320330
import { 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

‎packages/objectql/src/home-amount-projection.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,3 +451,45 @@ export async function projectHomeAmounts(
451451
}
452452
return out;
453453
}
454+
455+
/**
456+
* The one patch a PREDICATE update can store for the projected fields of the
457+
* entries it touches, judged over every matched row's projection. `updateMany`
458+
* writes ONE patch to all of them, so a projected field is set only when it
459+
* comes out the same on every matched row (the payload moved every row to one
460+
* currency, amount and date, say). A patch that would project different
461+
* values on different rows cannot be one statement, and is refused before
462+
* anything is written: update those rows by id, where each is projected from
463+
* its own stored row. A caller's value for a projected field never stands
464+
* either way — the stored-expression rule for a predicate update, applied to
465+
* the home projection.
466+
*/
467+
export function settleBulkHomeAmounts(
468+
touched: readonly HomeAmountPlanEntry[],
469+
patch: Record<string, unknown>,
470+
projected: ReadonlyArray<Record<string, unknown>>,
471+
object: string,
472+
): Record<string, unknown> {
473+
const out: Record<string, unknown> = { ...patch };
474+
const refused: Array<{ field: string; code: 'invalid_value'; message: string }> = [];
475+
for (const entry of touched) {
476+
for (const name of [entry.homeField, entry.rateField]) {
477+
delete out[name];
478+
if (projected.length === 0) continue;
479+
const first = projected[0]![name];
480+
if (projected.every((values) => Object.is(values[name], first))) {
481+
if (first !== undefined) out[name] = first;
482+
continue;
483+
}
484+
refused.push({
485+
field: name,
486+
code: 'invalid_value',
487+
message: `'${name}' on '${object}' is the engine's conversion of '${entry.amountField}' into the home currency, `
488+
+ 'and this update moves its inputs on rows where it then converts to different values, which one update '
489+
+ 'of all the matched rows cannot store; update those rows by id, so each is converted from its own row',
490+
});
491+
}
492+
}
493+
if (refused.length > 0) throw new ValidationError(refused);
494+
return out;
495+
}

0 commit comments

Comments
 (0)