@abloatai/transaction 0.55.0 → 0.57.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.
- package/CONVENTIONS.md +34 -0
- package/dist/auth/hostedEndpoints.d.ts +21 -5
- package/dist/auth/hostedEndpoints.d.ts.map +1 -1
- package/dist/auth/hostedEndpoints.js +21 -5
- package/dist/auth/hostedEndpoints.js.map +1 -1
- package/dist/auth/index.d.ts +1 -1
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +1 -1
- package/dist/auth/index.js.map +1 -1
- package/dist/coordination/index.d.ts +2 -2
- package/dist/coordination/index.d.ts.map +1 -1
- package/dist/coordination/index.js +1 -1
- package/dist/coordination/index.js.map +1 -1
- package/dist/coordination/schema.d.ts +0 -3
- package/dist/coordination/schema.d.ts.map +1 -1
- package/dist/coordination/schema.js +4 -5
- package/dist/coordination/schema.js.map +1 -1
- package/dist/errorCodes.d.ts +1 -0
- package/dist/errorCodes.d.ts.map +1 -1
- package/dist/errorCodes.js +1 -0
- package/dist/errorCodes.js.map +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +4 -1
- package/dist/errors.js.map +1 -1
- package/dist/log/syncDeltaRow.d.ts +3 -3
- package/dist/readSetContext.d.ts.map +1 -1
- package/dist/readSetContext.js +8 -3
- package/dist/readSetContext.js.map +1 -1
- package/dist/resources/httpResources.d.ts +43 -22
- package/dist/resources/httpResources.d.ts.map +1 -1
- package/dist/resources/httpResources.js +81 -4
- package/dist/resources/httpResources.js.map +1 -1
- package/dist/resources/modelCreate.d.ts +21 -0
- package/dist/resources/modelCreate.d.ts.map +1 -0
- package/dist/resources/modelCreate.js +49 -0
- package/dist/resources/modelCreate.js.map +1 -0
- package/dist/resources/modelOperations.d.ts +24 -2
- package/dist/resources/modelOperations.d.ts.map +1 -1
- package/dist/resources/modelOperations.js.map +1 -1
- package/dist/resources/writeOptionsSchema.d.ts +14 -0
- package/dist/resources/writeOptionsSchema.d.ts.map +1 -1
- package/dist/resources/writeOptionsSchema.js +23 -2
- package/dist/resources/writeOptionsSchema.js.map +1 -1
- package/dist/schema/audit.d.ts +15 -0
- package/dist/schema/audit.d.ts.map +1 -0
- package/dist/schema/audit.js +90 -0
- package/dist/schema/audit.js.map +1 -0
- package/dist/schema/ddl.d.ts.map +1 -1
- package/dist/schema/ddl.js +68 -1
- package/dist/schema/ddl.js.map +1 -1
- package/dist/schema/index.d.ts +3 -1
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +3 -1
- package/dist/schema/index.js.map +1 -1
- package/dist/schema/model.d.ts +14 -0
- package/dist/schema/model.d.ts.map +1 -1
- package/dist/schema/model.js +2 -0
- package/dist/schema/model.js.map +1 -1
- package/dist/schema/openapi.d.ts.map +1 -1
- package/dist/schema/openapi.js +104 -2
- package/dist/schema/openapi.js.map +1 -1
- package/dist/schema/roles.d.ts +27 -0
- package/dist/schema/roles.d.ts.map +1 -1
- package/dist/schema/roles.js +40 -0
- package/dist/schema/roles.js.map +1 -1
- package/dist/schema/schema.d.ts +23 -4
- package/dist/schema/schema.d.ts.map +1 -1
- package/dist/schema/schema.js +30 -1
- package/dist/schema/schema.js.map +1 -1
- package/dist/schema/serialize.d.ts +4 -1
- package/dist/schema/serialize.d.ts.map +1 -1
- package/dist/schema/serialize.js +5 -1
- package/dist/schema/serialize.js.map +1 -1
- package/dist/schema/subject.d.ts +18 -0
- package/dist/schema/subject.d.ts.map +1 -0
- package/dist/schema/subject.js +27 -0
- package/dist/schema/subject.js.map +1 -0
- package/dist/server/adapter.d.ts +2 -0
- package/dist/server/adapter.d.ts.map +1 -1
- package/dist/server/readConfig.d.ts +3 -0
- package/dist/server/readConfig.d.ts.map +1 -1
- package/dist/server/readConfig.js +0 -21
- package/dist/server/readConfig.js.map +1 -1
- package/dist/source/adapters/drizzle.d.ts.map +1 -1
- package/dist/source/adapters/drizzle.js +37 -6
- package/dist/source/adapters/drizzle.js.map +1 -1
- package/dist/source/adapters/kysely.d.ts +2 -0
- package/dist/source/adapters/kysely.d.ts.map +1 -1
- package/dist/source/adapters/kysely.js +33 -5
- package/dist/source/adapters/kysely.js.map +1 -1
- package/dist/source/adapters/kyselyMutationCore.d.ts +4 -1
- package/dist/source/adapters/kyselyMutationCore.d.ts.map +1 -1
- package/dist/source/adapters/kyselyMutationCore.js +17 -8
- package/dist/source/adapters/kyselyMutationCore.js.map +1 -1
- package/dist/source/adapters/memory.d.ts.map +1 -1
- package/dist/source/adapters/memory.js +1 -0
- package/dist/source/adapters/memory.js.map +1 -1
- package/dist/source/adapters/prisma.d.ts.map +1 -1
- package/dist/source/adapters/prisma.js +59 -7
- package/dist/source/adapters/prisma.js.map +1 -1
- package/dist/source/contract.d.ts +14 -0
- package/dist/source/contract.d.ts.map +1 -1
- package/dist/source/contract.js +10 -0
- package/dist/source/contract.js.map +1 -1
- package/dist/source/factory.d.ts +7 -1
- package/dist/source/factory.d.ts.map +1 -1
- package/dist/source/factory.js +116 -5
- package/dist/source/factory.js.map +1 -1
- package/dist/source/index.d.ts +2 -1
- package/dist/source/index.d.ts.map +1 -1
- package/dist/source/index.js +1 -0
- package/dist/source/index.js.map +1 -1
- package/dist/source/migrations.d.ts.map +1 -1
- package/dist/source/migrations.js +21 -0
- package/dist/source/migrations.js.map +1 -1
- package/dist/source/subjectAuthorization.d.ts +16 -0
- package/dist/source/subjectAuthorization.d.ts.map +1 -0
- package/dist/source/subjectAuthorization.js +126 -0
- package/dist/source/subjectAuthorization.js.map +1 -0
- package/dist/source/types.d.ts +52 -2
- package/dist/source/types.d.ts.map +1 -1
- package/dist/source/types.js +1 -0
- package/dist/source/types.js.map +1 -1
- package/dist/syncLog/contract.d.ts +45 -3
- package/dist/syncLog/contract.d.ts.map +1 -1
- package/dist/syncLog/contract.js +46 -4
- package/dist/syncLog/contract.js.map +1 -1
- package/dist/testing/fixtures/httpResponses.d.ts +6 -0
- package/dist/testing/fixtures/httpResponses.d.ts.map +1 -1
- package/dist/testing/fixtures/httpResponses.js +1 -0
- package/dist/testing/fixtures/httpResponses.js.map +1 -1
- package/dist/transport/httpClient.d.ts +8 -1
- package/dist/transport/httpClient.d.ts.map +1 -1
- package/dist/transport/httpClient.js +51 -20
- package/dist/transport/httpClient.js.map +1 -1
- package/dist/transport/httpCommitRequest.d.ts +45 -0
- package/dist/transport/httpCommitRequest.d.ts.map +1 -0
- package/dist/transport/httpCommitRequest.js +67 -0
- package/dist/transport/httpCommitRequest.js.map +1 -0
- package/dist/transport/httpTransport.d.ts.map +1 -1
- package/dist/transport/httpTransport.js +69 -106
- package/dist/transport/httpTransport.js.map +1 -1
- package/dist/transport/httpTransportHelpers.d.ts +10 -0
- package/dist/transport/httpTransportHelpers.d.ts.map +1 -0
- package/dist/transport/httpTransportHelpers.js +46 -0
- package/dist/transport/httpTransportHelpers.js.map +1 -0
- package/dist/wire/apiLifecycle.d.ts +63 -0
- package/dist/wire/apiLifecycle.d.ts.map +1 -0
- package/dist/wire/apiLifecycle.js +89 -0
- package/dist/wire/apiLifecycle.js.map +1 -0
- package/dist/wire/auth.d.ts.map +1 -1
- package/dist/wire/auth.js +6 -3
- package/dist/wire/auth.js.map +1 -1
- package/dist/wire/commit.d.ts +74 -9
- package/dist/wire/commit.d.ts.map +1 -1
- package/dist/wire/commit.js +18 -5
- package/dist/wire/commit.js.map +1 -1
- package/dist/wire/index.d.ts +5 -2
- package/dist/wire/index.d.ts.map +1 -1
- package/dist/wire/index.js +12 -1
- package/dist/wire/index.js.map +1 -1
- package/dist/wire/modelMutations.js +3 -2
- package/dist/wire/modelMutations.js.map +1 -1
- package/dist/wire/rateLimit.d.ts +82 -0
- package/dist/wire/rateLimit.d.ts.map +1 -0
- package/dist/wire/rateLimit.js +142 -0
- package/dist/wire/rateLimit.js.map +1 -0
- package/package.json +1 -1
- package/src/auth/hostedEndpoints.ts +23 -5
- package/src/auth/index.ts +2 -0
- package/src/coordination/index.ts +0 -2
- package/src/coordination/schema.ts +4 -7
- package/src/errorCodes.ts +6 -0
- package/src/errors.ts +4 -1
- package/src/readSetContext.ts +8 -2
- package/src/resources/httpResources.ts +116 -25
- package/src/resources/modelCreate.ts +73 -0
- package/src/resources/modelOperations.ts +29 -2
- package/src/resources/writeOptionsSchema.ts +30 -2
- package/src/schema/audit.ts +121 -0
- package/src/schema/ddl.ts +71 -1
- package/src/schema/index.ts +16 -0
- package/src/schema/model.ts +17 -0
- package/src/schema/openapi.ts +123 -2
- package/src/schema/roles.ts +53 -0
- package/src/schema/schema.ts +68 -3
- package/src/schema/serialize.ts +8 -1
- package/src/schema/subject.ts +43 -0
- package/src/server/adapter.ts +2 -0
- package/src/server/readConfig.ts +4 -0
- package/src/source/adapters/drizzle.ts +50 -6
- package/src/source/adapters/kysely.ts +47 -4
- package/src/source/adapters/kyselyMutationCore.ts +21 -9
- package/src/source/adapters/memory.ts +1 -0
- package/src/source/adapters/prisma.ts +70 -7
- package/src/source/contract.ts +11 -0
- package/src/source/factory.ts +141 -5
- package/src/source/index.ts +6 -0
- package/src/source/migrations.ts +21 -0
- package/src/source/subjectAuthorization.ts +182 -0
- package/src/source/types.ts +56 -2
- package/src/syncLog/contract.ts +47 -5
- package/src/testing/fixtures/httpResponses.ts +7 -0
- package/src/transport/httpClient.ts +85 -23
- package/src/transport/httpCommitRequest.ts +104 -0
- package/src/transport/httpTransport.ts +92 -142
- package/src/transport/httpTransportHelpers.ts +63 -0
- package/src/wire/apiLifecycle.ts +94 -0
- package/src/wire/auth.ts +6 -3
- package/src/wire/commit.ts +20 -5
- package/src/wire/index.ts +31 -0
- package/src/wire/modelMutations.ts +2 -2
- package/src/wire/rateLimit.ts +155 -0
package/src/schema/roles.ts
CHANGED
|
@@ -219,6 +219,8 @@ export const groupsInputSchema = z.object({
|
|
|
219
219
|
root: scopeSchema.optional(),
|
|
220
220
|
grants: grantsRefSchema.optional(),
|
|
221
221
|
roles: z.union([entityRoleSchema, z.array(entityRoleSchema)]).optional(),
|
|
222
|
+
/** Acknowledge that these groups are delivery routing, not row access. */
|
|
223
|
+
routingOnly: z.literal(true).optional(),
|
|
222
224
|
});
|
|
223
225
|
export type GroupsInput = z.infer<typeof groupsInputSchema>;
|
|
224
226
|
|
|
@@ -314,6 +316,57 @@ export function composeEntitySyncGroups(
|
|
|
314
316
|
return Array.from(out);
|
|
315
317
|
}
|
|
316
318
|
|
|
319
|
+
/** The row fields needed to derive its durable delivery groups. */
|
|
320
|
+
export interface RecordSyncGroupSpec {
|
|
321
|
+
readonly subject?: { readonly kind: string; readonly field: string };
|
|
322
|
+
readonly selfKind?: string;
|
|
323
|
+
readonly parents: readonly { readonly kind: string; readonly field: string }[];
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** A subject-scoped row cannot be routed safely without its declared subject. */
|
|
327
|
+
export class InvalidRecordSubjectError extends Error {
|
|
328
|
+
readonly field: string;
|
|
329
|
+
|
|
330
|
+
constructor(field: string) {
|
|
331
|
+
super(`Record lacks non-empty subject field "${field}".`);
|
|
332
|
+
this.name = 'InvalidRecordSubjectError';
|
|
333
|
+
this.field = field;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Derive the exact durable delivery groups for a row.
|
|
339
|
+
*
|
|
340
|
+
* Delivery matching is OR-based, so a declared subject is an exclusive
|
|
341
|
+
* authorization route: self, parent, entity-role, writer, and transitive groups
|
|
342
|
+
* must not become alternate ways to receive the row. Additional groups are
|
|
343
|
+
* accepted here so every producer applies that rule in this one place.
|
|
344
|
+
*/
|
|
345
|
+
export function syncGroupsForRow(
|
|
346
|
+
spec: RecordSyncGroupSpec | undefined,
|
|
347
|
+
record: Readonly<Record<string, unknown>>,
|
|
348
|
+
additionalGroups: readonly string[] = [],
|
|
349
|
+
): string[] {
|
|
350
|
+
if (spec?.subject) {
|
|
351
|
+
const value = record[spec.subject.field];
|
|
352
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
353
|
+
throw new InvalidRecordSubjectError(spec.subject.field);
|
|
354
|
+
}
|
|
355
|
+
return [syncGroup(spec.subject.kind, value)];
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const groups = new Set<string>(additionalGroups);
|
|
359
|
+
if (!spec) return [...groups];
|
|
360
|
+
if (spec.selfKind && typeof record.id === 'string' && record.id.length > 0) {
|
|
361
|
+
groups.add(syncGroup(spec.selfKind, record.id));
|
|
362
|
+
}
|
|
363
|
+
for (const parent of spec.parents) {
|
|
364
|
+
const value = record[parent.field];
|
|
365
|
+
if (value) groups.add(syncGroup(parent.kind, String(value)));
|
|
366
|
+
}
|
|
367
|
+
return [...groups];
|
|
368
|
+
}
|
|
369
|
+
|
|
317
370
|
/**
|
|
318
371
|
* Narrow a client's requested sync-group set to the groups it is actually
|
|
319
372
|
* allowed to subscribe to. This helper is fully generic — it never inspects the
|
package/src/schema/schema.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { AbloValidationError } from '../errors.js';
|
|
|
27
27
|
import type { IdentityRole } from './roles.js';
|
|
28
28
|
import { fieldRef, type FieldRef } from './fieldRef.js';
|
|
29
29
|
import { scopeSchema, grantsRefSchema } from './roles.js';
|
|
30
|
+
import { subjectRuleSchema } from './subject.js';
|
|
30
31
|
|
|
31
32
|
// Sync-group roles (identity and entity) are defined in `./roles.js` and
|
|
32
33
|
// re-exported here so they can also be imported from this module. See
|
|
@@ -49,6 +50,9 @@ export {
|
|
|
49
50
|
extractEntityIds,
|
|
50
51
|
composeIdentitySyncGroups,
|
|
51
52
|
composeEntitySyncGroups,
|
|
53
|
+
syncGroupsForRow,
|
|
54
|
+
InvalidRecordSubjectError,
|
|
55
|
+
type RecordSyncGroupSpec,
|
|
52
56
|
intersectRequestedWithAllowed,
|
|
53
57
|
syncGroup,
|
|
54
58
|
identityAnchor,
|
|
@@ -150,6 +154,7 @@ export const RESERVED_SESSION_SETTINGS: readonly string[] = [
|
|
|
150
154
|
'app.current_participant_id',
|
|
151
155
|
'app.current_participant_kind',
|
|
152
156
|
'app.current_user_id',
|
|
157
|
+
'app.current_subject_groups',
|
|
153
158
|
];
|
|
154
159
|
|
|
155
160
|
export interface DefineSchemaOptions {
|
|
@@ -523,14 +528,34 @@ export type InsertValue<S extends Schema, ModelName extends keyof S['models']> =
|
|
|
523
528
|
export type UpsertValue<S extends Schema, ModelName extends keyof S['models']> =
|
|
524
529
|
InsertValue<S, ModelName>;
|
|
525
530
|
|
|
531
|
+
/**
|
|
532
|
+
* A patch over a row's fields: send a field to change it, omit it to leave it,
|
|
533
|
+
* send `null` to CLEAR it.
|
|
534
|
+
*
|
|
535
|
+
* The third of those is why this is a mapped type rather than `Partial`.
|
|
536
|
+
* Omitting a field and clearing a field are different intentions that
|
|
537
|
+
* `Partial` spells the same way, because `undefined` is what an absent key
|
|
538
|
+
* already means: it is dropped from the payload, so a caller who wrote
|
|
539
|
+
* `{ assigneeId: undefined }` to unassign kept the old assignee, and the
|
|
540
|
+
* unassign, the un-project and the cleared due date all did nothing without
|
|
541
|
+
* erroring. `null` is the value that clears, so `null` is what the type has to
|
|
542
|
+
* accept — otherwise the only way to write a working clear is to cast the
|
|
543
|
+
* payload, and a cast is what people reached for.
|
|
544
|
+
*
|
|
545
|
+
* Only a field the schema lets be absent is clearable. A required field cannot
|
|
546
|
+
* be nulled, which is the same rule the column has.
|
|
547
|
+
*/
|
|
548
|
+
export type Clearable<T> = {
|
|
549
|
+
[K in keyof T]?: undefined extends T[K] ? T[K] | null : T[K];
|
|
550
|
+
};
|
|
551
|
+
|
|
526
552
|
/**
|
|
527
553
|
* The value type for updating an existing row. `id` is required (identifies
|
|
528
|
-
* the row to update);
|
|
529
|
-
* are changed).
|
|
554
|
+
* the row to update); every other field is a {@link Clearable} patch.
|
|
530
555
|
*/
|
|
531
556
|
export type UpdateValue<S extends Schema, ModelName extends keyof S['models']> =
|
|
532
557
|
S['models'][ModelName] extends ModelDef<infer Shape>
|
|
533
|
-
? { id: string } &
|
|
558
|
+
? { id: string } & Clearable<z.input<z.ZodObject<Shape>>>
|
|
534
559
|
: never;
|
|
535
560
|
|
|
536
561
|
/**
|
|
@@ -708,6 +733,7 @@ export function defineSchema<const S extends SchemaRecord>(
|
|
|
708
733
|
}
|
|
709
734
|
|
|
710
735
|
validateSyncGroupSchema(resolvedModels);
|
|
736
|
+
validateSubjectSchema(resolvedModels);
|
|
711
737
|
validateSessionSettings(options?.sessionSettings ?? {});
|
|
712
738
|
|
|
713
739
|
return {
|
|
@@ -721,6 +747,45 @@ export function defineSchema<const S extends SchemaRecord>(
|
|
|
721
747
|
};
|
|
722
748
|
}
|
|
723
749
|
|
|
750
|
+
function validateSubjectSchema(models: Record<string, ModelDef>): void {
|
|
751
|
+
for (const [name, def] of Object.entries(models)) {
|
|
752
|
+
if (!def.subject) continue;
|
|
753
|
+
const parsed = subjectRuleSchema.safeParse(def.subject);
|
|
754
|
+
if (!parsed.success) {
|
|
755
|
+
throw new AbloValidationError(
|
|
756
|
+
`Model "${name}": subject must be { field, group } with valid identifiers.`,
|
|
757
|
+
{ code: 'schema_definition_invalid', param: `${name}.subject` },
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
if (!(def.subject.field in def.fields)) {
|
|
761
|
+
throw new AbloValidationError(
|
|
762
|
+
`Model "${name}": subject.field "${def.subject.field}" is not a declared field on this model.`,
|
|
763
|
+
{ code: 'schema_definition_invalid', param: `${name}.subject.field` },
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
const field = def.shape[def.subject.field];
|
|
767
|
+
const meta = def.fields[def.subject.field];
|
|
768
|
+
if (!field || !meta) {
|
|
769
|
+
throw new AbloValidationError(
|
|
770
|
+
`Model "${name}": subject.field "${def.subject.field}" is not a declared field on this model.`,
|
|
771
|
+
{ code: 'schema_definition_invalid', param: `${name}.subject.field` },
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
const subjectField = field as z.ZodType;
|
|
775
|
+
const acceptsInvalidValue =
|
|
776
|
+
subjectField.safeParse('').success ||
|
|
777
|
+
subjectField.safeParse(null).success ||
|
|
778
|
+
subjectField.safeParse(undefined).success ||
|
|
779
|
+
subjectField.safeParse(1).success;
|
|
780
|
+
if (!['string', 'enum'].includes(meta.type) || meta.isOptional || acceptsInvalidValue) {
|
|
781
|
+
throw new AbloValidationError(
|
|
782
|
+
`Model "${name}": subject.field "${def.subject.field}" must be a required, non-empty string field.`,
|
|
783
|
+
{ code: 'schema_definition_invalid', param: `${name}.subject.field` },
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
724
789
|
/**
|
|
725
790
|
* Reject session-setting mappings that couldn't do what the author intends —
|
|
726
791
|
* caught here at definition time rather than silently dropped on the write path.
|
package/src/schema/serialize.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* What round-trips:
|
|
14
14
|
* - all model routing and scoping metadata: typename, tableName, load,
|
|
15
15
|
* mutable, the `tenancy` descriptor, bootstrap hints, scope, grants,
|
|
16
|
-
* entityRoles, the `conflict` disposition map, persist, autoFill,
|
|
16
|
+
* entityRoles, the routing-only acknowledgement, the `conflict` disposition map, persist, autoFill,
|
|
17
17
|
* requiredFields, and lazyObservable. The authoring shorthands (`policy`
|
|
18
18
|
* and `groups`) are normalized into these canonical fields when the model
|
|
19
19
|
* is built, so only the canonical fields cross here.
|
|
@@ -33,6 +33,7 @@ import type { FieldMeta } from './field.js';
|
|
|
33
33
|
import { buildFieldRefs } from './schema.js';
|
|
34
34
|
import type { Tenancy } from './tenancy.js';
|
|
35
35
|
import type { ModelResidency } from './residency.js';
|
|
36
|
+
import type { SubjectRule } from './subject.js';
|
|
36
37
|
import type {
|
|
37
38
|
ModelDef,
|
|
38
39
|
RelationRecord,
|
|
@@ -80,6 +81,7 @@ export interface ModelJSON {
|
|
|
80
81
|
readonly typename: string;
|
|
81
82
|
readonly tableName?: string;
|
|
82
83
|
readonly tenancy: Tenancy;
|
|
84
|
+
readonly subject?: SubjectRule;
|
|
83
85
|
/** The database plane the model's rows live in. Optional for backward
|
|
84
86
|
* compatibility: when absent (an artifact written before this field existed)
|
|
85
87
|
* it reads as `tenant`, the default. See {@link ModelResidency}. */
|
|
@@ -87,6 +89,7 @@ export interface ModelJSON {
|
|
|
87
89
|
readonly scope?: boolean | string;
|
|
88
90
|
readonly grants?: GrantsRef;
|
|
89
91
|
readonly entityRoles?: readonly EntityRole[];
|
|
92
|
+
readonly routingOnly?: true;
|
|
90
93
|
/** The declared write-conflict disposition per committer kind. When absent,
|
|
91
94
|
* the engine falls back to its default. */
|
|
92
95
|
readonly conflict?: ConflictAxis;
|
|
@@ -136,10 +139,12 @@ function modelToJSON(def: ModelDef): ModelJSON {
|
|
|
136
139
|
typename: def.typename ?? '',
|
|
137
140
|
tableName: def.tableName,
|
|
138
141
|
tenancy: def.tenancy,
|
|
142
|
+
subject: def.subject,
|
|
139
143
|
plane: def.plane,
|
|
140
144
|
scope: def.scope,
|
|
141
145
|
grants: def.grants,
|
|
142
146
|
entityRoles: def.entityRoles,
|
|
147
|
+
routingOnly: def.routingOnly,
|
|
143
148
|
conflict: def.conflict,
|
|
144
149
|
bootstrapLimit: def.bootstrapLimit,
|
|
145
150
|
bootstrapOrderBy: def.bootstrapOrderBy,
|
|
@@ -272,12 +277,14 @@ function modelFromJSON(json: ModelJSON): ModelDef {
|
|
|
272
277
|
persist: json.persist,
|
|
273
278
|
tableName: json.tableName,
|
|
274
279
|
tenancy: json.tenancy,
|
|
280
|
+
subject: json.subject,
|
|
275
281
|
// Absent in older artifacts → default `tenant`, matching the model builder
|
|
276
282
|
// and provisioning defaults so the round-trip stays stable.
|
|
277
283
|
plane: json.plane ?? 'tenant',
|
|
278
284
|
scope: json.scope,
|
|
279
285
|
grants: json.grants,
|
|
280
286
|
entityRoles: json.entityRoles,
|
|
287
|
+
routingOnly: json.routingOnly,
|
|
281
288
|
// Absent in older artifacts → undefined, so the commit path falls through to
|
|
282
289
|
// the function registry or the engine default.
|
|
283
290
|
conflict: json.conflict,
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Row/subject authorization for one model.
|
|
5
|
+
*
|
|
6
|
+
* A row is authorized exactly when the authenticated request carries the sync
|
|
7
|
+
* group `${group}:${row[field]}`. Both values are plain schema data so the rule
|
|
8
|
+
* survives serialization and can be compiled by every storage plane.
|
|
9
|
+
*/
|
|
10
|
+
export const subjectRuleSchema = z.strictObject({
|
|
11
|
+
field: z.string().regex(
|
|
12
|
+
/^[a-zA-Z_][a-zA-Z0-9_]*$/,
|
|
13
|
+
'subject.field must name a model field',
|
|
14
|
+
),
|
|
15
|
+
group: z.string().regex(
|
|
16
|
+
/^[a-z][a-z0-9_]*$/,
|
|
17
|
+
'subject.group must be a lowercase identifier, e.g. "workspace"',
|
|
18
|
+
),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export type SubjectRule = z.infer<typeof subjectRuleSchema>;
|
|
22
|
+
|
|
23
|
+
/** The exact trusted group a row must match, or null for a malformed row. */
|
|
24
|
+
export function subjectGroupForRow(
|
|
25
|
+
rule: SubjectRule,
|
|
26
|
+
row: Readonly<Record<string, unknown>>,
|
|
27
|
+
): string | null {
|
|
28
|
+
const value = row[rule.field];
|
|
29
|
+
return typeof value === 'string' && value.length > 0
|
|
30
|
+
? `${rule.group}:${value}`
|
|
31
|
+
: null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Fail-closed row check shared by endpoint adapters and in-memory log folds. */
|
|
35
|
+
export function subjectAuthorized(
|
|
36
|
+
rule: SubjectRule | undefined,
|
|
37
|
+
row: Readonly<Record<string, unknown>>,
|
|
38
|
+
groups: readonly string[] | null | undefined,
|
|
39
|
+
): boolean {
|
|
40
|
+
if (!rule) return true;
|
|
41
|
+
const required = subjectGroupForRow(rule, row);
|
|
42
|
+
return required !== null && (groups ?? []).includes(required);
|
|
43
|
+
}
|
package/src/server/adapter.ts
CHANGED
|
@@ -25,6 +25,7 @@ import type { ServerSyncDelta } from '../wire/delta.js';
|
|
|
25
25
|
import type { BootstrapModel } from './readConfig.js';
|
|
26
26
|
import type { CommitContext, CommitExecutionResult } from './commit.js';
|
|
27
27
|
import type { StorageMode } from './storageMode.js';
|
|
28
|
+
import type { SubjectRule } from '../schema/subject.js';
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
31
|
* A canonical database row: one record, keyed by column name. The value type is
|
|
@@ -141,6 +142,7 @@ export type ReadRequest =
|
|
|
141
142
|
readonly sourceModel?: string;
|
|
142
143
|
/** `__typename` stamped on each returned row. */
|
|
143
144
|
readonly typename: string;
|
|
145
|
+
readonly subject?: SubjectRule;
|
|
144
146
|
readonly query: SourceListQuery;
|
|
145
147
|
readonly scope?: SourceRequestContext;
|
|
146
148
|
/** Runs the query against a hosted database: compile, take the tenant pool, apply row-level security, unpack the rows. */
|
package/src/server/readConfig.ts
CHANGED
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
* Plain data, no database driver — it feeds the read side of the data adapter
|
|
20
20
|
* contract, and your query builder reads it to load a model's initial rows.
|
|
21
21
|
*/
|
|
22
|
+
import type { SubjectRule } from '../schema/subject.js';
|
|
23
|
+
|
|
22
24
|
|
|
23
25
|
/** A mapping from a declared field to a physical column, with the alias to apply after a `SELECT *`. */
|
|
24
26
|
export interface ColumnOverride {
|
|
@@ -88,6 +90,8 @@ export interface ReadModelShape {
|
|
|
88
90
|
readonly orgColumn?: string;
|
|
89
91
|
/** Parent-table scoping for rows with no tenancy column of their own. */
|
|
90
92
|
readonly scopedVia?: ParentScope;
|
|
93
|
+
/** Credential-bound row authorization compiled by every read plane. */
|
|
94
|
+
readonly subject?: SubjectRule;
|
|
91
95
|
/** Client-facing field name → physical DB column for declared fields. */
|
|
92
96
|
readonly fieldColumns?: Readonly<Record<string, string>>;
|
|
93
97
|
/** Physical-column aliases needed after SELECT * for `.from(...)` fields. */
|
|
@@ -58,6 +58,15 @@ import { toSchemaJSON } from '../../schema/serialize.js';
|
|
|
58
58
|
import { camelToSnake, snakeToCamel } from '../../schema/ddl.js';
|
|
59
59
|
import { tenancyColumn } from '../../schema/tenancy.js';
|
|
60
60
|
import { ABLO_POSTGRES_COMMIT_ECHO_PREFIX } from '../types.js';
|
|
61
|
+
import {
|
|
62
|
+
authorizeSourceChange,
|
|
63
|
+
authorizeSourceRead,
|
|
64
|
+
lockSourceSubjectCreates,
|
|
65
|
+
rethrowStrictCreateConflict,
|
|
66
|
+
sourceSyncGroups,
|
|
67
|
+
sourceSubjectRule,
|
|
68
|
+
sourceSubjectValues,
|
|
69
|
+
} from '../subjectAuthorization.js';
|
|
61
70
|
|
|
62
71
|
/** The subset of a Drizzle database/transaction handle the adapter calls. */
|
|
63
72
|
export interface DrizzleLike {
|
|
@@ -214,11 +223,18 @@ export function drizzleDataSource<S extends SchemaRecord>(
|
|
|
214
223
|
const table = sql.identifier(mc.table);
|
|
215
224
|
if (req.kind === 'load') {
|
|
216
225
|
const rows = rowsOf(await db.execute(sql`SELECT * FROM ${table} WHERE id = ${req.id} LIMIT 1`));
|
|
217
|
-
return rows.map((r) => toFields(mc, r));
|
|
226
|
+
return authorizeSourceRead(schema, req, rows.map((r) => toFields(mc, r)));
|
|
218
227
|
}
|
|
219
228
|
const limit = req.query?.limit ?? 1000;
|
|
220
|
-
const
|
|
221
|
-
|
|
229
|
+
const rule = sourceSubjectRule(schema, req.model);
|
|
230
|
+
const subjects = sourceSubjectValues(rule, req.scope?.syncGroups);
|
|
231
|
+
if (subjects?.length === 0) return [];
|
|
232
|
+
const rows = rowsOf(await db.execute(subjects
|
|
233
|
+
? sql`SELECT * FROM ${table}
|
|
234
|
+
WHERE ${sql.identifier(columnFor(mc, rule!.field))} = ANY(${subjects})
|
|
235
|
+
LIMIT ${limit}`
|
|
236
|
+
: sql`SELECT * FROM ${table} LIMIT ${limit}`));
|
|
237
|
+
return authorizeSourceRead(schema, req, rows.map((r) => toFields(mc, r)));
|
|
222
238
|
},
|
|
223
239
|
|
|
224
240
|
async commit(change: ChangeSet): Promise<AdapterCommitResult> {
|
|
@@ -238,19 +254,46 @@ export function drizzleDataSource<S extends SchemaRecord>(
|
|
|
238
254
|
return { rows: cachedRow.response as Row[] };
|
|
239
255
|
}
|
|
240
256
|
|
|
257
|
+
await lockSourceSubjectCreates(schema, change, async (_operation, key) => {
|
|
258
|
+
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${key}, 0))`);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
await authorizeSourceChange(schema, change, async (operation) => {
|
|
262
|
+
if (!operation.id) return null;
|
|
263
|
+
const mc = modelColumns(operation.model);
|
|
264
|
+
const found = rowsOf(
|
|
265
|
+
await tx.execute(
|
|
266
|
+
sql`SELECT * FROM ${sql.identifier(mc.table)} WHERE id = ${operation.id} LIMIT 1 FOR UPDATE`,
|
|
267
|
+
),
|
|
268
|
+
)[0];
|
|
269
|
+
return found ? toFields(mc, found) : null;
|
|
270
|
+
});
|
|
271
|
+
|
|
241
272
|
const rows: Row[] = [];
|
|
242
273
|
for (const [index, op] of change.operations.entries()) {
|
|
243
|
-
|
|
274
|
+
let row: Row;
|
|
275
|
+
try {
|
|
276
|
+
row = await applyOperation(tx, op);
|
|
277
|
+
} catch (error) {
|
|
278
|
+
if (op.type === 'CREATE') rethrowStrictCreateConflict(error, op);
|
|
279
|
+
throw error;
|
|
280
|
+
}
|
|
244
281
|
rows.push(row);
|
|
245
282
|
const entityId = String(row.id ?? rowId(op));
|
|
283
|
+
const syncGroups = sourceSyncGroups(schema, op.model, row);
|
|
284
|
+
const syncGroupsSql = sql`ARRAY[${sql.join(
|
|
285
|
+
syncGroups.map((group) => sql`${group}`),
|
|
286
|
+
sql`, `,
|
|
287
|
+
)}]::text[]`;
|
|
246
288
|
await tx.execute(sql`
|
|
247
289
|
INSERT INTO ablo_outbox (
|
|
248
|
-
id, model, entity_id, type, data,
|
|
290
|
+
id, model, entity_id, type, data, sync_groups,
|
|
249
291
|
correlation_id, transaction_id, occurred_at
|
|
250
292
|
)
|
|
251
293
|
VALUES (
|
|
252
294
|
${`${change.correlationId}:${index}`}, ${op.model}, ${entityId}, ${op.type},
|
|
253
295
|
${op.type === 'DELETE' ? null : JSON.stringify(row)}::jsonb,
|
|
296
|
+
${syncGroupsSql},
|
|
254
297
|
${change.correlationId}, ${op.transactionId ?? null}, ${Date.now()}
|
|
255
298
|
)`);
|
|
256
299
|
}
|
|
@@ -274,7 +317,7 @@ export function drizzleDataSource<S extends SchemaRecord>(
|
|
|
274
317
|
const after = cursor ?? '0';
|
|
275
318
|
const rows = rowsOf(
|
|
276
319
|
await db.execute(sql`
|
|
277
|
-
SELECT cursor, id, model, entity_id, type, data, organization_id,
|
|
320
|
+
SELECT cursor, id, model, entity_id, type, data, sync_groups, organization_id,
|
|
278
321
|
client_tx_id, correlation_id, transaction_id, occurred_at
|
|
279
322
|
FROM ablo_outbox WHERE cursor > ${after} ORDER BY cursor ASC LIMIT ${limit}`),
|
|
280
323
|
);
|
|
@@ -285,6 +328,7 @@ export function drizzleDataSource<S extends SchemaRecord>(
|
|
|
285
328
|
entityId: r.entity_id,
|
|
286
329
|
type: r.type,
|
|
287
330
|
data: r.data ?? null,
|
|
331
|
+
syncGroups: r.sync_groups ?? [],
|
|
288
332
|
organizationId: r.organization_id ?? null,
|
|
289
333
|
clientTxId: r.client_tx_id ?? null,
|
|
290
334
|
correlationId: r.correlation_id ?? null,
|
|
@@ -46,6 +46,13 @@ import {
|
|
|
46
46
|
idempotencyLedgerMigrations,
|
|
47
47
|
} from '../migrations.js';
|
|
48
48
|
import { ABLO_POSTGRES_COMMIT_ECHO_PREFIX } from '../types.js';
|
|
49
|
+
import {
|
|
50
|
+
authorizeSourceChange,
|
|
51
|
+
authorizeSourceRead,
|
|
52
|
+
lockSourceSubjectCreates,
|
|
53
|
+
rethrowStrictCreateConflict,
|
|
54
|
+
sourceSyncGroups,
|
|
55
|
+
} from '../subjectAuthorization.js';
|
|
49
56
|
import {
|
|
50
57
|
createKyselyMutationCore,
|
|
51
58
|
kyselyOperationRowId,
|
|
@@ -259,6 +266,8 @@ export interface KyselyMutationAdapterOptions {
|
|
|
259
266
|
* typenames equal its keys.
|
|
260
267
|
*/
|
|
261
268
|
readonly markerModelFor?: (operationModel: string) => string;
|
|
269
|
+
/** Schema required to enforce row/subject authorization in this adapter. */
|
|
270
|
+
readonly schema?: Schema;
|
|
262
271
|
}
|
|
263
272
|
|
|
264
273
|
export function createKyselyMutationAdapter(
|
|
@@ -287,8 +296,9 @@ export function createKyselyMutationAdapter(
|
|
|
287
296
|
: idempotencyLedgerMigrations();
|
|
288
297
|
},
|
|
289
298
|
|
|
290
|
-
read(request) {
|
|
291
|
-
|
|
299
|
+
async read(request) {
|
|
300
|
+
const rows = await core.read(request);
|
|
301
|
+
return options.schema ? authorizeSourceRead(options.schema, request, rows) : rows;
|
|
292
302
|
},
|
|
293
303
|
|
|
294
304
|
async commit(change: ChangeSet): Promise<AdapterCommitResult> {
|
|
@@ -326,6 +336,25 @@ export function createKyselyMutationAdapter(
|
|
|
326
336
|
return { rows: parseCachedRows(cachedRow.response) };
|
|
327
337
|
}
|
|
328
338
|
|
|
339
|
+
if (options.schema) {
|
|
340
|
+
await lockSourceSubjectCreates(options.schema, request, async (_operation, key) => {
|
|
341
|
+
await transaction.executeQuery(rawQuery(
|
|
342
|
+
'subject-create-lock',
|
|
343
|
+
'SELECT pg_advisory_xact_lock(hashtextextended($1, 0))',
|
|
344
|
+
[key],
|
|
345
|
+
));
|
|
346
|
+
});
|
|
347
|
+
await authorizeSourceChange(options.schema, request, async (operation) => {
|
|
348
|
+
if (!operation.id) return null;
|
|
349
|
+
const rows = await core.read(
|
|
350
|
+
{ kind: 'load', model: operation.model, id: operation.id },
|
|
351
|
+
transaction,
|
|
352
|
+
{ forUpdate: true },
|
|
353
|
+
);
|
|
354
|
+
return rows[0] ?? null;
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
329
358
|
// Direct mode dispatches every operation before awaiting any of them, so
|
|
330
359
|
// the driver pipelines them into one trip instead of paying a full
|
|
331
360
|
// round trip per row. Postgres still executes them IN ORDER on the
|
|
@@ -339,9 +368,17 @@ export function createKyselyMutationAdapter(
|
|
|
339
368
|
// returned row, so operation i+1's write cannot be dispatched before
|
|
340
369
|
// operation i has answered.
|
|
341
370
|
const rows: Row[] = [];
|
|
371
|
+
const applyStrict = async (operation: ChangeSet['operations'][number]): Promise<Row> => {
|
|
372
|
+
try {
|
|
373
|
+
return await core.applyOperation(transaction, operation);
|
|
374
|
+
} catch (error) {
|
|
375
|
+
if (operation.type === 'CREATE') rethrowStrictCreateConflict(error, operation);
|
|
376
|
+
throw error;
|
|
377
|
+
}
|
|
378
|
+
};
|
|
342
379
|
if (mode === 'direct') {
|
|
343
380
|
const dispatched = request.operations.map((operation) =>
|
|
344
|
-
|
|
381
|
+
applyStrict(operation),
|
|
345
382
|
);
|
|
346
383
|
// Settle every dispatch before inspecting, so a later rejection is
|
|
347
384
|
// never an unhandled rejection, then surface the FIRST failure in
|
|
@@ -354,7 +391,7 @@ export function createKyselyMutationAdapter(
|
|
|
354
391
|
}
|
|
355
392
|
} else {
|
|
356
393
|
for (const [index, operation] of request.operations.entries()) {
|
|
357
|
-
const row = await
|
|
394
|
+
const row = await applyStrict(operation);
|
|
358
395
|
rows.push(row);
|
|
359
396
|
|
|
360
397
|
const entityId = String(row.id ?? kyselyOperationRowId(operation));
|
|
@@ -366,6 +403,9 @@ export function createKyselyMutationAdapter(
|
|
|
366
403
|
entity_id: entityId,
|
|
367
404
|
type: operation.type,
|
|
368
405
|
data: operation.type === 'DELETE' ? null : JSON.stringify(row),
|
|
406
|
+
sync_groups: options.schema
|
|
407
|
+
? sourceSyncGroups(options.schema, operation.model, row)
|
|
408
|
+
: [],
|
|
369
409
|
correlation_id: request.correlationId,
|
|
370
410
|
transaction_id: operation.transactionId ?? null,
|
|
371
411
|
occurred_at: Date.now(),
|
|
@@ -402,6 +442,7 @@ export function kyselyDataSource<S extends SchemaRecord>(
|
|
|
402
442
|
db,
|
|
403
443
|
createKyselyMutationCore(db, schema),
|
|
404
444
|
'endpoint',
|
|
445
|
+
{ schema },
|
|
405
446
|
);
|
|
406
447
|
|
|
407
448
|
return defineDatabaseAdapter({
|
|
@@ -423,6 +464,7 @@ export function kyselyDataSource<S extends SchemaRecord>(
|
|
|
423
464
|
entityId: row.entity_id,
|
|
424
465
|
type: row.type,
|
|
425
466
|
data: typeof row.data === 'string' ? JSON.parse(row.data) : row.data ?? null,
|
|
467
|
+
syncGroups: row.sync_groups ?? [],
|
|
426
468
|
organizationId: row.organization_id ?? null,
|
|
427
469
|
clientTxId: row.client_tx_id ?? null,
|
|
428
470
|
correlationId: row.correlation_id ?? null,
|
|
@@ -458,6 +500,7 @@ export function kyselyDirectMutation<S extends SchemaRecord>(
|
|
|
458
500
|
markerModels.get(operationModel) ??
|
|
459
501
|
markerModels.get(operationModel.toLowerCase()) ??
|
|
460
502
|
operationModel,
|
|
503
|
+
schema,
|
|
461
504
|
},
|
|
462
505
|
);
|
|
463
506
|
}
|
|
@@ -15,6 +15,7 @@ import { camelToSnake, snakeToCamel } from '../../schema/ddl.js';
|
|
|
15
15
|
import { tenancyColumn } from '../../schema/tenancy.js';
|
|
16
16
|
import type { AdapterReadRequest, Row } from '../adapter.js';
|
|
17
17
|
import type { Operation } from '../contract.js';
|
|
18
|
+
import { sourceSubjectRule, sourceSubjectValues } from '../subjectAuthorization.js';
|
|
18
19
|
|
|
19
20
|
/** The subset of a Kysely instance, or transaction handle, used by the core. */
|
|
20
21
|
export interface KyselyLike {
|
|
@@ -48,6 +49,7 @@ export interface KyselySelectBuilder {
|
|
|
48
49
|
where(column: string, operator: string, value: unknown): KyselySelectBuilder;
|
|
49
50
|
orderBy(column: string, direction: 'asc' | 'desc'): KyselySelectBuilder;
|
|
50
51
|
limit(limit: number): KyselySelectBuilder;
|
|
52
|
+
forUpdate(): KyselySelectBuilder;
|
|
51
53
|
execute(): Promise<readonly Row[]>;
|
|
52
54
|
}
|
|
53
55
|
|
|
@@ -140,7 +142,11 @@ function suppliedOperationRowId(operation: Operation): string | undefined {
|
|
|
140
142
|
|
|
141
143
|
/** The transport-independent Kysely field/column mutation boundary. */
|
|
142
144
|
export interface KyselyMutationCore {
|
|
143
|
-
read(
|
|
145
|
+
read(
|
|
146
|
+
request: AdapterReadRequest,
|
|
147
|
+
database?: KyselyLike,
|
|
148
|
+
options?: { readonly forUpdate?: boolean },
|
|
149
|
+
): Promise<readonly Row[]>;
|
|
144
150
|
applyOperation(transaction: KyselyLike, operation: Operation): Promise<Row>;
|
|
145
151
|
}
|
|
146
152
|
|
|
@@ -185,22 +191,28 @@ export function createKyselyMutationCore<S extends SchemaRecord>(
|
|
|
185
191
|
};
|
|
186
192
|
|
|
187
193
|
return {
|
|
188
|
-
async read(request): Promise<readonly Row[]> {
|
|
194
|
+
async read(request, database = db, options): Promise<readonly Row[]> {
|
|
189
195
|
const columns = modelColumns(request.model);
|
|
190
196
|
if (request.kind === 'load') {
|
|
191
|
-
|
|
197
|
+
let query = database
|
|
192
198
|
.selectFrom(columns.table)
|
|
193
199
|
.selectAll()
|
|
194
200
|
.where(columnFor(columns, 'id'), '=', request.id)
|
|
195
|
-
.limit(1)
|
|
196
|
-
|
|
201
|
+
.limit(1);
|
|
202
|
+
if (options?.forUpdate) query = query.forUpdate();
|
|
203
|
+
const rows = await query.execute();
|
|
197
204
|
return rows.map((row) => toFields(columns, row));
|
|
198
205
|
}
|
|
199
|
-
|
|
206
|
+
let query = database
|
|
200
207
|
.selectFrom(columns.table)
|
|
201
|
-
.selectAll()
|
|
202
|
-
|
|
203
|
-
|
|
208
|
+
.selectAll();
|
|
209
|
+
const rule = sourceSubjectRule(schema, request.model);
|
|
210
|
+
const subjects = sourceSubjectValues(rule, request.scope?.syncGroups);
|
|
211
|
+
if (subjects) {
|
|
212
|
+
if (subjects.length === 0) return [];
|
|
213
|
+
query = query.where(columnFor(columns, rule!.field), 'in', subjects);
|
|
214
|
+
}
|
|
215
|
+
const rows = await query.limit(request.query?.limit ?? 1000).execute();
|
|
204
216
|
return rows.map((row) => toFields(columns, row));
|
|
205
217
|
},
|
|
206
218
|
|
|
@@ -141,6 +141,7 @@ export function memoryDataSource(): DataSourceAdapter {
|
|
|
141
141
|
entityId: String(row.id ?? rowId(op)),
|
|
142
142
|
type: op.type,
|
|
143
143
|
data: op.type === 'DELETE' ? null : row,
|
|
144
|
+
syncGroups: [],
|
|
144
145
|
correlationId: change.correlationId,
|
|
145
146
|
...(op.transactionId ? { transactionId: op.transactionId } : {}),
|
|
146
147
|
cursor: String(outbox.length + 1),
|