@korajs/server 1.0.0-beta.2 → 1.0.0-beta.4

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/index.d.cts CHANGED
@@ -5,6 +5,32 @@ import { S as ServerTransport, a as ServerMessageHandler, b as ServerCloseHandle
5
5
  import { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
6
6
  import { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
7
7
 
8
+ /**
9
+ * Predicate evaluation for conditional route mutations.
10
+ *
11
+ * A predicate is a small, total comparison language evaluated against the current
12
+ * materialized state of a single record at admission time. It is deliberately not
13
+ * a general query language: it exists so a server route can express an atomic
14
+ * admission guard (for example "accept this response only while the form is still
15
+ * below its cap") without pulling a query DSL into the write path.
16
+ */
17
+ /**
18
+ * Comparison operators applied to one field. All operators present on a field
19
+ * must hold for the field to match. Numeric operators (`$lt`, `$lte`, `$gt`,
20
+ * `$gte`) require the record value to be a number; a non-numeric value fails.
21
+ */
22
+ interface RoutePredicateOperators {
23
+ $eq?: unknown;
24
+ $ne?: unknown;
25
+ $lt?: number;
26
+ $lte?: number;
27
+ $gt?: number;
28
+ $gte?: number;
29
+ $in?: readonly unknown[];
30
+ }
31
+ /** A predicate: every listed field must satisfy all of its operators. */
32
+ type RoutePredicate = Record<string, RoutePredicateOperators>;
33
+
8
34
  /**
9
35
  * Per-collection scope map from auth context.
10
36
  */
@@ -463,6 +489,61 @@ type RouteApplyResult = {
463
489
  message: string;
464
490
  retriable: boolean;
465
491
  };
492
+ /**
493
+ * A conditional, multi-collection admission gate applied atomically per instance.
494
+ *
495
+ * The gate reads the current state of the target record (`collection` + `id`),
496
+ * checks the `if` predicate against it, and only then applies the `update` to the
497
+ * target plus every mutation in `also` as one set. If the predicate fails it
498
+ * applies nothing and returns the structured `reject`. `idempotencyKey` names a
499
+ * record whose prior existence proves the set already committed, so a retry
500
+ * returns the earlier outcome instead of applying the set (and its non-idempotent
501
+ * counter increments) again.
502
+ *
503
+ * Atomicity note: within one server instance the check and the writes do not
504
+ * interleave with other route mutations. Cross-instance, race-free admission and
505
+ * all-or-nothing across a mid-set crash require the store-level conditional
506
+ * transaction (a Postgres `WHERE ... < cap` commit), which is the next increment.
507
+ */
508
+ interface RouteConditionalApply {
509
+ /** Collection of the record the predicate reads and the `update` targets. */
510
+ collection: string;
511
+ /** Id of the target record. */
512
+ id: string;
513
+ /** Admission predicate evaluated against the target's current state. */
514
+ if?: RoutePredicate;
515
+ /** Partial update applied to the target record when the predicate holds. */
516
+ update?: Record<string, unknown> | null;
517
+ /** Additional mutations committed atomically with the target update. */
518
+ also?: RouteMutation[];
519
+ /** Structured rejection returned when the predicate does not hold. */
520
+ reject?: {
521
+ code: string;
522
+ message: string;
523
+ };
524
+ /**
525
+ * Names a record whose existence means this set already committed. When it is
526
+ * present the set is skipped and the prior outcome returned. Use a stable,
527
+ * client-generated id (for example the accepted record's own id).
528
+ */
529
+ idempotencyKey?: {
530
+ collection: string;
531
+ id: string;
532
+ };
533
+ }
534
+ /** Result of {@link ProductionHttpRouteContext.applyConditional}. */
535
+ type RouteConditionalResult = {
536
+ ok: true;
537
+ /** True when the set was already committed and this call was a no-op. */
538
+ idempotent: boolean;
539
+ operations: Operation[];
540
+ records: (MaterializedRecord | null)[];
541
+ } | {
542
+ ok: false;
543
+ code: string;
544
+ message: string;
545
+ retriable: boolean;
546
+ };
466
547
  /**
467
548
  * Scoped, validated data-plane access handed to custom HTTP route handlers.
468
549
  *
@@ -477,6 +558,12 @@ interface ProductionHttpRouteContext {
477
558
  * resulting operation(s) to connected clients.
478
559
  */
479
560
  apply(mutation: RouteMutation, options?: RouteScopeOptions): Promise<RouteApplyResult>;
561
+ /**
562
+ * Conditionally apply a multi-collection mutation set as one admission gate:
563
+ * read the target, check the predicate, and commit the update plus every `also`
564
+ * mutation only if it holds, with at-most-once semantics via `idempotencyKey`.
565
+ */
566
+ applyConditional(spec: RouteConditionalApply, options?: RouteScopeOptions): Promise<RouteConditionalResult>;
480
567
  /** Read materialized records from a collection, optionally scoped. */
481
568
  query(collection: string, options?: CollectionQueryOptions & RouteScopeOptions): Promise<MaterializedRecord[]>;
482
569
  /** Read a single materialized record by id, optionally scoped. */
@@ -1562,4 +1649,4 @@ declare class AwarenessRelay {
1562
1649
  */
1563
1650
  declare function createKoraServer(config: KoraSyncServerConfig): KoraSyncServer;
1564
1651
 
1565
- export { type AuthContext, type AuthProvider, AwarenessRelay, type AwarenessRelayCallback, type ClientMetrics, ClientSession, type ClientSessionOptions, type CollectionQueryOptions, type HttpPollResponse, HttpServerTransport, type HttpSyncRequest, type HttpSyncResponse, KoraAuthProvider, type KoraAuthProviderOptions, KoraSyncServer, type KoraSyncServerConfig, type LogEntry, type LogLevel, type Logger, type MaterializedRecord, MemoryServerStore, MixedAuthProvider, type MixedAuthProviderOptions, NoAuthProvider, type OperationDecision, type OperationRejection, type OperationValidationContext, type OperationValidator, PostgresServerStore, type ProductionHttpRoute, type ProductionHttpRouteContext, type ProductionHttpRouteRequest, type ProductionHttpRouteResponse, type ProductionServer, type ProductionServerConfig, RETRIABLE_REJECTION_CODES, type RelayCallback, type RouteApplyResult, type RouteMutation, type RouteScopeOptions, ServerCloseHandler, ServerErrorHandler, ServerMessageHandler, ServerMetricsCollector, type ServerMetricsSnapshot, type ServerStatus, type ServerStore, ServerTransport, type SessionState, SqliteServerStore, TokenAuthProvider, type TokenAuthProviderOptions, type WsServerConstructor, type WsServerLike, WsServerTransport, type WsServerTransportOptions, type WsWebSocket, createDefaultLogger, createJsonLogger, createKoraServer, createPostgresServerStore, createPrettyLogger, createProductionServer, createSilentLogger, createSqliteServerStore, isRetriableRejection };
1652
+ export { type AuthContext, type AuthProvider, AwarenessRelay, type AwarenessRelayCallback, type ClientMetrics, ClientSession, type ClientSessionOptions, type CollectionQueryOptions, type HttpPollResponse, HttpServerTransport, type HttpSyncRequest, type HttpSyncResponse, KoraAuthProvider, type KoraAuthProviderOptions, KoraSyncServer, type KoraSyncServerConfig, type LogEntry, type LogLevel, type Logger, type MaterializedRecord, MemoryServerStore, MixedAuthProvider, type MixedAuthProviderOptions, NoAuthProvider, type OperationDecision, type OperationRejection, type OperationValidationContext, type OperationValidator, PostgresServerStore, type ProductionHttpRoute, type ProductionHttpRouteContext, type ProductionHttpRouteRequest, type ProductionHttpRouteResponse, type ProductionServer, type ProductionServerConfig, RETRIABLE_REJECTION_CODES, type RelayCallback, type RouteApplyResult, type RouteConditionalApply, type RouteConditionalResult, type RouteMutation, type RoutePredicate, type RoutePredicateOperators, type RouteScopeOptions, ServerCloseHandler, ServerErrorHandler, ServerMessageHandler, ServerMetricsCollector, type ServerMetricsSnapshot, type ServerStatus, type ServerStore, ServerTransport, type SessionState, SqliteServerStore, TokenAuthProvider, type TokenAuthProviderOptions, type WsServerConstructor, type WsServerLike, WsServerTransport, type WsServerTransportOptions, type WsWebSocket, createDefaultLogger, createJsonLogger, createKoraServer, createPostgresServerStore, createPrettyLogger, createProductionServer, createSilentLogger, createSqliteServerStore, isRetriableRejection };
package/dist/index.d.ts CHANGED
@@ -5,6 +5,32 @@ import { S as ServerTransport, a as ServerMessageHandler, b as ServerCloseHandle
5
5
  import { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
6
6
  import { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
7
7
 
8
+ /**
9
+ * Predicate evaluation for conditional route mutations.
10
+ *
11
+ * A predicate is a small, total comparison language evaluated against the current
12
+ * materialized state of a single record at admission time. It is deliberately not
13
+ * a general query language: it exists so a server route can express an atomic
14
+ * admission guard (for example "accept this response only while the form is still
15
+ * below its cap") without pulling a query DSL into the write path.
16
+ */
17
+ /**
18
+ * Comparison operators applied to one field. All operators present on a field
19
+ * must hold for the field to match. Numeric operators (`$lt`, `$lte`, `$gt`,
20
+ * `$gte`) require the record value to be a number; a non-numeric value fails.
21
+ */
22
+ interface RoutePredicateOperators {
23
+ $eq?: unknown;
24
+ $ne?: unknown;
25
+ $lt?: number;
26
+ $lte?: number;
27
+ $gt?: number;
28
+ $gte?: number;
29
+ $in?: readonly unknown[];
30
+ }
31
+ /** A predicate: every listed field must satisfy all of its operators. */
32
+ type RoutePredicate = Record<string, RoutePredicateOperators>;
33
+
8
34
  /**
9
35
  * Per-collection scope map from auth context.
10
36
  */
@@ -463,6 +489,61 @@ type RouteApplyResult = {
463
489
  message: string;
464
490
  retriable: boolean;
465
491
  };
492
+ /**
493
+ * A conditional, multi-collection admission gate applied atomically per instance.
494
+ *
495
+ * The gate reads the current state of the target record (`collection` + `id`),
496
+ * checks the `if` predicate against it, and only then applies the `update` to the
497
+ * target plus every mutation in `also` as one set. If the predicate fails it
498
+ * applies nothing and returns the structured `reject`. `idempotencyKey` names a
499
+ * record whose prior existence proves the set already committed, so a retry
500
+ * returns the earlier outcome instead of applying the set (and its non-idempotent
501
+ * counter increments) again.
502
+ *
503
+ * Atomicity note: within one server instance the check and the writes do not
504
+ * interleave with other route mutations. Cross-instance, race-free admission and
505
+ * all-or-nothing across a mid-set crash require the store-level conditional
506
+ * transaction (a Postgres `WHERE ... < cap` commit), which is the next increment.
507
+ */
508
+ interface RouteConditionalApply {
509
+ /** Collection of the record the predicate reads and the `update` targets. */
510
+ collection: string;
511
+ /** Id of the target record. */
512
+ id: string;
513
+ /** Admission predicate evaluated against the target's current state. */
514
+ if?: RoutePredicate;
515
+ /** Partial update applied to the target record when the predicate holds. */
516
+ update?: Record<string, unknown> | null;
517
+ /** Additional mutations committed atomically with the target update. */
518
+ also?: RouteMutation[];
519
+ /** Structured rejection returned when the predicate does not hold. */
520
+ reject?: {
521
+ code: string;
522
+ message: string;
523
+ };
524
+ /**
525
+ * Names a record whose existence means this set already committed. When it is
526
+ * present the set is skipped and the prior outcome returned. Use a stable,
527
+ * client-generated id (for example the accepted record's own id).
528
+ */
529
+ idempotencyKey?: {
530
+ collection: string;
531
+ id: string;
532
+ };
533
+ }
534
+ /** Result of {@link ProductionHttpRouteContext.applyConditional}. */
535
+ type RouteConditionalResult = {
536
+ ok: true;
537
+ /** True when the set was already committed and this call was a no-op. */
538
+ idempotent: boolean;
539
+ operations: Operation[];
540
+ records: (MaterializedRecord | null)[];
541
+ } | {
542
+ ok: false;
543
+ code: string;
544
+ message: string;
545
+ retriable: boolean;
546
+ };
466
547
  /**
467
548
  * Scoped, validated data-plane access handed to custom HTTP route handlers.
468
549
  *
@@ -477,6 +558,12 @@ interface ProductionHttpRouteContext {
477
558
  * resulting operation(s) to connected clients.
478
559
  */
479
560
  apply(mutation: RouteMutation, options?: RouteScopeOptions): Promise<RouteApplyResult>;
561
+ /**
562
+ * Conditionally apply a multi-collection mutation set as one admission gate:
563
+ * read the target, check the predicate, and commit the update plus every `also`
564
+ * mutation only if it holds, with at-most-once semantics via `idempotencyKey`.
565
+ */
566
+ applyConditional(spec: RouteConditionalApply, options?: RouteScopeOptions): Promise<RouteConditionalResult>;
480
567
  /** Read materialized records from a collection, optionally scoped. */
481
568
  query(collection: string, options?: CollectionQueryOptions & RouteScopeOptions): Promise<MaterializedRecord[]>;
482
569
  /** Read a single materialized record by id, optionally scoped. */
@@ -1562,4 +1649,4 @@ declare class AwarenessRelay {
1562
1649
  */
1563
1650
  declare function createKoraServer(config: KoraSyncServerConfig): KoraSyncServer;
1564
1651
 
1565
- export { type AuthContext, type AuthProvider, AwarenessRelay, type AwarenessRelayCallback, type ClientMetrics, ClientSession, type ClientSessionOptions, type CollectionQueryOptions, type HttpPollResponse, HttpServerTransport, type HttpSyncRequest, type HttpSyncResponse, KoraAuthProvider, type KoraAuthProviderOptions, KoraSyncServer, type KoraSyncServerConfig, type LogEntry, type LogLevel, type Logger, type MaterializedRecord, MemoryServerStore, MixedAuthProvider, type MixedAuthProviderOptions, NoAuthProvider, type OperationDecision, type OperationRejection, type OperationValidationContext, type OperationValidator, PostgresServerStore, type ProductionHttpRoute, type ProductionHttpRouteContext, type ProductionHttpRouteRequest, type ProductionHttpRouteResponse, type ProductionServer, type ProductionServerConfig, RETRIABLE_REJECTION_CODES, type RelayCallback, type RouteApplyResult, type RouteMutation, type RouteScopeOptions, ServerCloseHandler, ServerErrorHandler, ServerMessageHandler, ServerMetricsCollector, type ServerMetricsSnapshot, type ServerStatus, type ServerStore, ServerTransport, type SessionState, SqliteServerStore, TokenAuthProvider, type TokenAuthProviderOptions, type WsServerConstructor, type WsServerLike, WsServerTransport, type WsServerTransportOptions, type WsWebSocket, createDefaultLogger, createJsonLogger, createKoraServer, createPostgresServerStore, createPrettyLogger, createProductionServer, createSilentLogger, createSqliteServerStore, isRetriableRejection };
1652
+ export { type AuthContext, type AuthProvider, AwarenessRelay, type AwarenessRelayCallback, type ClientMetrics, ClientSession, type ClientSessionOptions, type CollectionQueryOptions, type HttpPollResponse, HttpServerTransport, type HttpSyncRequest, type HttpSyncResponse, KoraAuthProvider, type KoraAuthProviderOptions, KoraSyncServer, type KoraSyncServerConfig, type LogEntry, type LogLevel, type Logger, type MaterializedRecord, MemoryServerStore, MixedAuthProvider, type MixedAuthProviderOptions, NoAuthProvider, type OperationDecision, type OperationRejection, type OperationValidationContext, type OperationValidator, PostgresServerStore, type ProductionHttpRoute, type ProductionHttpRouteContext, type ProductionHttpRouteRequest, type ProductionHttpRouteResponse, type ProductionServer, type ProductionServerConfig, RETRIABLE_REJECTION_CODES, type RelayCallback, type RouteApplyResult, type RouteConditionalApply, type RouteConditionalResult, type RouteMutation, type RoutePredicate, type RoutePredicateOperators, type RouteScopeOptions, ServerCloseHandler, ServerErrorHandler, ServerMessageHandler, ServerMetricsCollector, type ServerMetricsSnapshot, type ServerStatus, type ServerStore, ServerTransport, type SessionState, SqliteServerStore, TokenAuthProvider, type TokenAuthProviderOptions, type WsServerConstructor, type WsServerLike, WsServerTransport, type WsServerTransportOptions, type WsWebSocket, createDefaultLogger, createJsonLogger, createKoraServer, createPostgresServerStore, createPrettyLogger, createProductionServer, createSilentLogger, createSqliteServerStore, isRetriableRejection };
package/dist/index.js CHANGED
@@ -3068,7 +3068,64 @@ var YjsDocRelay = class {
3068
3068
  };
3069
3069
 
3070
3070
  // src/server/route-context.ts
3071
- import { HybridLogicalClock as HybridLogicalClock2, createOperation as createOperation2, generateUUIDv7 as generateUUIDv76 } from "@korajs/core";
3071
+ import {
3072
+ HybridLogicalClock as HybridLogicalClock2,
3073
+ createOperation as createOperation2,
3074
+ generateUUIDv7 as generateUUIDv76,
3075
+ isAtomicOp,
3076
+ resolveAtomicOp,
3077
+ toAtomicOp
3078
+ } from "@korajs/core";
3079
+
3080
+ // src/apply/route-predicate.ts
3081
+ function evaluateField(actual, operators) {
3082
+ if ("$eq" in operators && !Object.is(actual, operators.$eq)) {
3083
+ return false;
3084
+ }
3085
+ if ("$ne" in operators && Object.is(actual, operators.$ne)) {
3086
+ return false;
3087
+ }
3088
+ if (operators.$in !== void 0 && !operators.$in.some((candidate) => Object.is(actual, candidate))) {
3089
+ return false;
3090
+ }
3091
+ const numericChecks = [
3092
+ ["$lt", (a, b) => a < b],
3093
+ ["$lte", (a, b) => a <= b],
3094
+ ["$gt", (a, b) => a > b],
3095
+ ["$gte", (a, b) => a >= b]
3096
+ ];
3097
+ for (const [operator, compare] of numericChecks) {
3098
+ const bound = operators[operator];
3099
+ if (bound === void 0) {
3100
+ continue;
3101
+ }
3102
+ if (typeof actual !== "number" || typeof bound !== "number") {
3103
+ return false;
3104
+ }
3105
+ if (!compare(actual, bound)) {
3106
+ return false;
3107
+ }
3108
+ }
3109
+ return true;
3110
+ }
3111
+ function evaluateRoutePredicate(record, predicate) {
3112
+ const fields = Object.keys(predicate);
3113
+ if (fields.length === 0) {
3114
+ return true;
3115
+ }
3116
+ if (record === null) {
3117
+ return false;
3118
+ }
3119
+ for (const field of fields) {
3120
+ const operators = predicate[field];
3121
+ if (operators && !evaluateField(record[field], operators)) {
3122
+ return false;
3123
+ }
3124
+ }
3125
+ return true;
3126
+ }
3127
+
3128
+ // src/server/route-context.ts
3072
3129
  function recordMatchesScope(collection, record, scope) {
3073
3130
  const collectionScope = scope[collection];
3074
3131
  if (!collectionScope) {
@@ -3119,7 +3176,18 @@ async function buildRouteOperation(store, mutation) {
3119
3176
  }
3120
3177
  const current = await store.findRecord(mutation.collection, mutation.recordId);
3121
3178
  if (mutation.type === "update") {
3122
- const data = mutation.data ?? {};
3179
+ const rawData = mutation.data ?? {};
3180
+ const data = {};
3181
+ const atomicOps = {};
3182
+ for (const key of Object.keys(rawData)) {
3183
+ const value = rawData[key];
3184
+ if (isAtomicOp(value)) {
3185
+ data[key] = resolveAtomicOp(current ? current[key] : void 0, value);
3186
+ atomicOps[key] = toAtomicOp(value);
3187
+ } else {
3188
+ data[key] = value;
3189
+ }
3190
+ }
3123
3191
  return createOperation2(
3124
3192
  {
3125
3193
  nodeId,
@@ -3127,10 +3195,11 @@ async function buildRouteOperation(store, mutation) {
3127
3195
  collection: mutation.collection,
3128
3196
  recordId: mutation.recordId,
3129
3197
  data,
3130
- previousData: current ? pickFields(current, Object.keys(data)) : {},
3198
+ previousData: current ? pickFields(current, Object.keys(rawData)) : {},
3131
3199
  sequenceNumber,
3132
3200
  causalDeps: [],
3133
- schemaVersion
3201
+ schemaVersion,
3202
+ ...Object.keys(atomicOps).length > 0 ? { atomicOps } : {}
3134
3203
  },
3135
3204
  clock
3136
3205
  );
@@ -3168,45 +3237,123 @@ function createRouteContext(server, store) {
3168
3237
  );
3169
3238
  return run;
3170
3239
  }
3240
+ async function prepareMutation(mutation, scope) {
3241
+ let op;
3242
+ try {
3243
+ op = await buildRouteOperation(store, mutation);
3244
+ } catch (error) {
3245
+ if (error instanceof RouteMutationError) {
3246
+ return {
3247
+ ok: false,
3248
+ rejection: {
3249
+ ok: false,
3250
+ code: error.code,
3251
+ message: error.message,
3252
+ retriable: isRetriableRejection(error.code)
3253
+ }
3254
+ };
3255
+ }
3256
+ throw error;
3257
+ }
3258
+ if (scope && !operationMatchesScopes(op, scope)) {
3259
+ return {
3260
+ ok: false,
3261
+ rejection: {
3262
+ ok: false,
3263
+ code: "SCOPE_VIOLATION",
3264
+ message: `Mutation on "${mutation.collection}" is outside the provided scope.`,
3265
+ retriable: isRetriableRejection("SCOPE_VIOLATION")
3266
+ }
3267
+ };
3268
+ }
3269
+ return { ok: true, op };
3270
+ }
3271
+ async function commitOperation(op, collection) {
3272
+ const result = await server.applyLocalOperation(op);
3273
+ if (result.result !== "applied") {
3274
+ const code = result.rejection?.code ?? "NOT_APPLIED";
3275
+ return {
3276
+ ok: false,
3277
+ code,
3278
+ message: result.rejection?.message ?? `Operation on "${collection}" was not applied (${result.result}).`,
3279
+ // Prefer the pipeline's own classification when present; fall back
3280
+ // to classifying the code so this stays correct if new codes appear.
3281
+ retriable: result.rejection?.retriable ?? isRetriableRejection(code)
3282
+ };
3283
+ }
3284
+ const record = await store.findRecord(collection, op.recordId);
3285
+ return { ok: true, operation: op, record };
3286
+ }
3287
+ async function rawApply(mutation, scope) {
3288
+ const prepared = await prepareMutation(mutation, scope);
3289
+ if (!prepared.ok) {
3290
+ return prepared.rejection;
3291
+ }
3292
+ return commitOperation(prepared.op, mutation.collection);
3293
+ }
3171
3294
  return {
3172
3295
  apply(mutation, options) {
3296
+ return serialize(() => rawApply(mutation, options?.scope));
3297
+ },
3298
+ applyConditional(spec, options) {
3173
3299
  return serialize(async () => {
3174
- let op;
3175
- try {
3176
- op = await buildRouteOperation(store, mutation);
3177
- } catch (error) {
3178
- if (error instanceof RouteMutationError) {
3300
+ if (spec.idempotencyKey) {
3301
+ const existing = await store.findRecord(
3302
+ spec.idempotencyKey.collection,
3303
+ spec.idempotencyKey.id
3304
+ );
3305
+ if (existing) {
3306
+ return { ok: true, idempotent: true, operations: [], records: [existing] };
3307
+ }
3308
+ }
3309
+ if (spec.if && Object.keys(spec.if).length > 0) {
3310
+ const target = await store.findRecord(spec.collection, spec.id);
3311
+ if (!evaluateRoutePredicate(target, spec.if)) {
3312
+ const code = spec.reject?.code ?? "CONDITION_NOT_MET";
3179
3313
  return {
3180
3314
  ok: false,
3181
- code: error.code,
3182
- message: error.message,
3183
- retriable: isRetriableRejection(error.code)
3315
+ code,
3316
+ message: spec.reject?.message ?? `Admission condition not met on "${spec.collection}" record "${spec.id}".`,
3317
+ retriable: false
3184
3318
  };
3185
3319
  }
3186
- throw error;
3187
3320
  }
3188
- if (options?.scope && !operationMatchesScopes(op, options.scope)) {
3189
- return {
3190
- ok: false,
3191
- code: "SCOPE_VIOLATION",
3192
- message: `Mutation on "${mutation.collection}" is outside the provided scope.`,
3193
- retriable: isRetriableRejection("SCOPE_VIOLATION")
3194
- };
3321
+ const mutations = [];
3322
+ if (spec.update !== void 0 && spec.update !== null) {
3323
+ mutations.push({
3324
+ collection: spec.collection,
3325
+ type: "update",
3326
+ recordId: spec.id,
3327
+ data: spec.update
3328
+ });
3195
3329
  }
3196
- const result = await server.applyLocalOperation(op);
3197
- if (result.result !== "applied") {
3198
- const code = result.rejection?.code ?? "NOT_APPLIED";
3199
- return {
3200
- ok: false,
3201
- code,
3202
- message: result.rejection?.message ?? `Operation on "${mutation.collection}" was not applied (${result.result}).`,
3203
- // Prefer the pipeline's own classification when present; fall back
3204
- // to classifying the code so this stays correct if new codes appear.
3205
- retriable: result.rejection?.retriable ?? isRetriableRejection(code)
3206
- };
3330
+ if (spec.also) {
3331
+ mutations.push(...spec.also);
3332
+ }
3333
+ const prepared = [];
3334
+ for (const mutation of mutations) {
3335
+ const p = await prepareMutation(mutation, options?.scope);
3336
+ if (!p.ok) {
3337
+ return p.rejection;
3338
+ }
3339
+ prepared.push({ op: p.op, collection: mutation.collection });
3340
+ }
3341
+ const operations2 = [];
3342
+ const records = [];
3343
+ for (const { op, collection } of prepared) {
3344
+ const result = await commitOperation(op, collection);
3345
+ if (!result.ok) {
3346
+ return {
3347
+ ok: false,
3348
+ code: result.code,
3349
+ message: result.message,
3350
+ retriable: result.retriable
3351
+ };
3352
+ }
3353
+ operations2.push(result.operation);
3354
+ records.push(result.record);
3207
3355
  }
3208
- const record = await store.findRecord(mutation.collection, op.recordId);
3209
- return { ok: true, operation: op, record };
3356
+ return { ok: true, idempotent: false, operations: operations2, records };
3210
3357
  });
3211
3358
  },
3212
3359
  async query(collection, options) {