@segmentstream-cloud/ontology 0.25.1 → 0.26.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.
@@ -1,9 +1,11 @@
1
- import { Effect, Option } from "effect";
1
+ import { Effect, Exit, Option } from "effect";
2
2
  import { beginReplicationWrite } from "../replication/transaction.js";
3
3
  import { isSqlError } from "effect/unstable/sql/SqlError";
4
- import { Conflict } from "../errors.js";
4
+ import { ActionRejected, Conflict } from "../errors.js";
5
5
  import { toSnakeCase } from "../names.js";
6
6
  import { User } from "../user.js";
7
+ import { ACTION_MAX_BATCH_ROWS, BATCH_MAX_BYTES, BATCH_MAX_ROWS, batchInputBytes, executeBatch, planBatch, } from "./batch.js";
8
+ import { logBatchConflict } from "./batch-diagnostics.js";
7
9
  import { InvalidQuery, WriteOutsideAction } from "./errors.js";
8
10
  import { entityModel, ontologyModel } from "./model.js";
9
11
  import { loadRecord, notFound, readHistory, relatedCondition, runQuery, validateQuery, } from "./reads.js";
@@ -23,7 +25,7 @@ function withoutUndefined(value) {
23
25
  .map(([key, entry]) => [key, withoutUndefined(entry)]));
24
26
  }
25
27
  /** A unique violation becomes Conflict, naming the property when the index covers one column. */
26
- function uniqueConflict(entity, error) {
28
+ function uniqueConflict(entity, error, batch) {
27
29
  const cause = (error.reason.cause ?? {});
28
30
  const unique = error.reason._tag === "UniqueViolation" || cause.code === "23505";
29
31
  if (!unique) {
@@ -31,17 +33,36 @@ function uniqueConflict(entity, error) {
31
33
  }
32
34
  const columns = typeof cause.detail === "string" ? /^Key \(([^)]*)\)=/.exec(cause.detail)?.[1] : undefined;
33
35
  const field = entity.fields.find((candidate) => candidate.column === columns);
36
+ const fields = typeof columns === "string"
37
+ ? columns.split(", ").flatMap((column) => {
38
+ const candidate = entity.fields.find((entry) => entry.column === column);
39
+ return candidate === undefined ? [] : [candidate.relationship ?? candidate.name];
40
+ })
41
+ : [];
42
+ const details = batch ? { batch: { indices: [], ...(fields.length > 0 ? { fields } : {}) } } : {};
34
43
  const label = entity.label.toLowerCase();
35
- return Effect.fail(field === undefined
44
+ const conflict = field === undefined
36
45
  ? new Conflict({
37
46
  entity: entity.name,
38
47
  message: `The ${label} conflicts with an existing one.`,
48
+ ...details,
39
49
  })
40
50
  : new Conflict({
41
51
  entity: entity.name,
42
52
  property: field.name,
43
53
  message: `A ${label} with this ${field.name} already exists.`,
44
- }));
54
+ ...details,
55
+ });
56
+ if (batch) {
57
+ // Keep the driver's complete cause available to operators without serializing SQL or values.
58
+ Object.defineProperty(conflict, "cause", { value: error, enumerable: false });
59
+ return logBatchConflict(entity, error, {
60
+ operation: batch.operation,
61
+ action: batch.scope.action,
62
+ actionId: batch.scope.actionId,
63
+ }).pipe(Effect.andThen(Effect.fail(conflict)));
64
+ }
65
+ return Effect.fail(conflict);
45
66
  }
46
67
  /**
47
68
  * Platform identity bookkeeping touches only User properties and their history. User is excluded
@@ -91,6 +112,8 @@ export function makeOntologyRuntime(ontology, sql, scope) {
91
112
  run: (text, params) => client.unsafe(text, params.values),
92
113
  };
93
114
  const entityOf = (name) => Effect.sync(() => entityModel(model, name));
115
+ let attemptedBatchRows = 0;
116
+ let attributionInitialized = false;
94
117
  const write = (entityName, operation) => Effect.gen(function* () {
95
118
  const entity = yield* entityOf(entityName);
96
119
  if (scope === undefined) {
@@ -108,14 +131,111 @@ export function makeOntologyRuntime(ontology, sql, scope) {
108
131
  for (const edit of edits) {
109
132
  scope.onEdit?.(edit);
110
133
  }
134
+ if (edits.length > 0 && scope.execution !== undefined) {
135
+ attributionInitialized = true;
136
+ }
111
137
  return result;
112
138
  });
139
+ const batch = (entityName, operation, input) => Effect.suspend(() => {
140
+ const startedAt = Date.now();
141
+ let statements = 0;
142
+ let distinctReferences = 0;
143
+ let counts = { created: 0, updated: 0, unchanged: 0 };
144
+ const run = Effect.gen(function* () {
145
+ if (scope === undefined) {
146
+ return yield* Effect.die(new WriteOutsideAction({
147
+ entity: entityName,
148
+ message: `Writes to ${entityName} run inside an action, which history records.`,
149
+ }));
150
+ }
151
+ const declared = ontology.entities.find((entity) => entity.name === entityName);
152
+ if (declared?.builtIn === true || declared?.entity.definition.source !== undefined) {
153
+ return yield* new ActionRejected({
154
+ message: "Batch writes require an ordinary stored entity.",
155
+ batch: { indices: [] },
156
+ });
157
+ }
158
+ const entity = yield* entityOf(entityName);
159
+ const length = input.records.length;
160
+ if (length > 0) {
161
+ attemptedBatchRows += length;
162
+ }
163
+ if (length > BATCH_MAX_ROWS || (length > 0 && attemptedBatchRows > ACTION_MAX_BATCH_ROWS)) {
164
+ return yield* new ActionRejected({
165
+ message: length > BATCH_MAX_ROWS
166
+ ? `A batch accepts at most ${BATCH_MAX_ROWS} records.`
167
+ : `An action accepts at most ${ACTION_MAX_BATCH_ROWS} attempted batch records.`,
168
+ batch: { indices: [] },
169
+ });
170
+ }
171
+ if (batchInputBytes(input) > BATCH_MAX_BYTES) {
172
+ return yield* new ActionRejected({
173
+ message: `A batch accepts at most ${BATCH_MAX_BYTES} UTF-8 JSON bytes.`,
174
+ batch: { indices: [] },
175
+ });
176
+ }
177
+ const plan = yield* planBatch(entity, operation, input);
178
+ distinctReferences = plan.distinctReferences;
179
+ if (length === 0) {
180
+ return plan.returning === "summary" ? counts : { ...counts, items: [] };
181
+ }
182
+ const enclosingTransaction = yield* Effect.serviceOption(sql.transactionService);
183
+ if (Option.isNone(enclosingTransaction)) {
184
+ return yield* Effect.die(new WriteOutsideAction({
185
+ entity: entityName,
186
+ message: "Batch writes require the enclosing action transaction.",
187
+ }));
188
+ }
189
+ const edits = [];
190
+ statements += 1; // The SQL client's nested transaction issues one savepoint.
191
+ const result = yield* sql
192
+ .withTransaction((scope.replicationInitialized ? Effect.void : beginReplicationWrite(sql)).pipe(Effect.andThen(executeBatch({
193
+ ...context,
194
+ scope,
195
+ edits,
196
+ attributionInitialized,
197
+ run: (text, params) => {
198
+ statements += 1;
199
+ return context.run(text, params);
200
+ },
201
+ }, entity, plan)), Effect.onExit((exit) => Effect.sync(() => {
202
+ if (Exit.isFailure(exit)) {
203
+ statements += 1; // A failed nested transaction rolls back to its savepoint.
204
+ }
205
+ }))))
206
+ .pipe(Effect.catchIf(isSqlError, (error) => uniqueConflict(entity, error, { operation, scope })));
207
+ counts = { created: result.created, updated: result.updated, unchanged: result.unchanged };
208
+ for (const edit of edits) {
209
+ scope.onEdit?.(edit);
210
+ }
211
+ if (edits.length > 0 && scope.execution !== undefined) {
212
+ attributionInitialized = true;
213
+ }
214
+ return result;
215
+ });
216
+ return run.pipe(Effect.onExit((exit) => Effect.logDebug("ontology.action.batch_completed").pipe(Effect.annotateLogs({
217
+ operation,
218
+ entity: entityName,
219
+ action: scope?.action,
220
+ actionId: scope?.actionId,
221
+ inputCount: input.records.length,
222
+ distinctReferenceCount: distinctReferences,
223
+ changedCount: counts.created + counts.updated,
224
+ unchangedCount: counts.unchanged,
225
+ sqlStatements: statements,
226
+ batchMs: Date.now() - startedAt,
227
+ outcome: Exit.isSuccess(exit) ? "success" : "failure",
228
+ }))));
229
+ });
113
230
  const query = (input) => Effect.gen(function* () {
114
231
  const entity = yield* entityOf(input.entity);
115
232
  const validated = yield* validateQuery(entity, input.query);
116
233
  return yield* orDieOnSql(runQuery(context, entity, validated));
117
234
  });
118
235
  return {
236
+ createMany: ((input) => batch(input.entity, "create", input)),
237
+ updateMany: ((input) => batch(input.entity, "update", input)),
238
+ upsertMany: ((input) => batch(input.entity, "upsert", input)),
119
239
  get: (input) => orDieOnSql(Effect.gen(function* () {
120
240
  const entity = yield* entityOf(input.entity);
121
241
  const record = yield* loadRecord(context, entity, input.id, input.forUpdate === true ? "update" : undefined);
package/dist/runtime.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Context, Effect } from "effect";
2
- import type { Conflict, NotFound } from "./errors.js";
2
+ import type { ActionRejected, Conflict, NotFound } from "./errors.js";
3
3
  import type { Where } from "./query.js";
4
4
  import type { WorkloadExecution } from "./runtime/workload.js";
5
5
  /** Who runs an action: an app member or a service principal, both `user` records. */
@@ -76,6 +76,51 @@ export interface UpsertResult<R = StoredRecord> {
76
76
  readonly record: R;
77
77
  readonly created: boolean;
78
78
  }
79
+ /** Whether a batch returns its records or only outcome counts. Defaults to `records`. */
80
+ export type BatchReturning = "records" | "summary";
81
+ export interface BatchOptions {
82
+ readonly returning?: BatchReturning;
83
+ }
84
+ /** Outcome counts, which together equal the number of inputs in a successful batch. */
85
+ export interface BatchSummaryResult {
86
+ readonly created: number;
87
+ readonly updated: number;
88
+ readonly unchanged: number;
89
+ }
90
+ /** One result per input, in input order, including records whose values did not change. */
91
+ export interface BatchRecordResult<R = StoredRecord> extends BatchSummaryResult {
92
+ readonly items: ReadonlyArray<{
93
+ readonly index: number;
94
+ readonly status: "created" | "updated" | "unchanged";
95
+ readonly record: R;
96
+ }>;
97
+ }
98
+ export type BatchResult<R = StoredRecord> = BatchRecordResult<R> | BatchSummaryResult;
99
+ /** Raw batch inputs use wire entity and field names. */
100
+ export interface CreateManyInput extends BatchOptions {
101
+ readonly entity: string;
102
+ readonly records: ReadonlyArray<StoredValues>;
103
+ }
104
+ export interface UpdateManyInput extends BatchOptions {
105
+ readonly entity: string;
106
+ readonly records: ReadonlyArray<{
107
+ readonly id: string;
108
+ readonly values: StoredValues;
109
+ }>;
110
+ }
111
+ export interface UpsertManyInput extends CreateManyInput {
112
+ readonly on: readonly [string, ...string[]];
113
+ }
114
+ type BatchWriteError = ActionRejected | Conflict | NotFound;
115
+ interface BatchWrite<Input extends BatchOptions> {
116
+ (input: Input & {
117
+ readonly returning: "summary";
118
+ }): Effect.Effect<BatchSummaryResult, BatchWriteError>;
119
+ (input: Input & {
120
+ readonly returning?: "records";
121
+ }): Effect.Effect<BatchRecordResult, BatchWriteError>;
122
+ (input: Input): Effect.Effect<BatchResult, BatchWriteError>;
123
+ }
79
124
  type ReadsService = {
80
125
  readonly get: (input: {
81
126
  readonly entity: string;
@@ -88,6 +133,9 @@ type ReadsService = {
88
133
  }) => Effect.Effect<ReadonlyArray<StoredRecord>>;
89
134
  };
90
135
  type WritesService = {
136
+ readonly createMany: BatchWrite<CreateManyInput>;
137
+ readonly updateMany: BatchWrite<UpdateManyInput>;
138
+ readonly upsertMany: BatchWrite<UpsertManyInput>;
91
139
  readonly create: (input: {
92
140
  readonly entity: string;
93
141
  readonly values: StoredValues;
@@ -130,7 +178,8 @@ export declare class OntologyReads extends OntologyReads_base {
130
178
  declare const OntologyWrites_base: Context.ServiceClass<OntologyWrites, "@segmentstream-cloud/ontology/OntologyWrites", WritesService>;
131
179
  /**
132
180
  * The writes an action handler runs, inside the action's transaction: `create`, `update`,
133
- * `upsertBy`, `delete`, `link` and `unlink` of entity handles require it. `prepare` never has it.
181
+ * `upsertBy`, the batch methods, `delete`, `link` and `unlink` of entity handles require it.
182
+ * `prepare` never has it.
134
183
  */
135
184
  export declare class OntologyWrites extends OntologyWrites_base {
136
185
  }
package/dist/runtime.js CHANGED
@@ -8,7 +8,8 @@ export class OntologyReads extends Context.Service()("@segmentstream-cloud/ontol
8
8
  }
9
9
  /**
10
10
  * The writes an action handler runs, inside the action's transaction: `create`, `update`,
11
- * `upsertBy`, `delete`, `link` and `unlink` of entity handles require it. `prepare` never has it.
11
+ * `upsertBy`, the batch methods, `delete`, `link` and `unlink` of entity handles require it.
12
+ * `prepare` never has it.
12
13
  */
13
14
  export class OntologyWrites extends Context.Service()("@segmentstream-cloud/ontology/OntologyWrites") {
14
15
  }
@@ -1,6 +1,7 @@
1
1
  import { Cause, Context, Effect, Option, Predicate, Schema, SchemaIssue } from "effect";
2
2
  import { HttpServerResponse } from "effect/unstable/http";
3
3
  import { HttpApiError } from "effect/unstable/httpapi";
4
+ import { BatchErrorDetails } from "../errors.js";
4
5
  /** The correlation id of the current request, in every error body and the `x-request-id` header. */
5
6
  export class RequestId extends Context.Service()("@segmentstream-cloud/ontology/RequestId") {
6
7
  }
@@ -64,6 +65,19 @@ function listed(values) {
64
65
  ? values.join("")
65
66
  : `${values.slice(0, -1).join(", ")} and ${values.at(-1)}`;
66
67
  }
68
+ /** Only the declared safe locations cross a transport, even for structurally tagged failures. */
69
+ function batchDetails(error) {
70
+ const batch = error.batch;
71
+ if (!Schema.is(BatchErrorDetails)(batch)) {
72
+ return {};
73
+ }
74
+ return {
75
+ batch: {
76
+ indices: batch.indices,
77
+ ...(batch.fields === undefined ? {} : { fields: batch.fields }),
78
+ },
79
+ };
80
+ }
67
81
  export function toServerResponse(response) {
68
82
  return HttpServerResponse.jsonUnsafe(errorBodyForWire(response.body), {
69
83
  status: response.status,
@@ -323,6 +337,7 @@ export function expectedFailure(error, requestId) {
323
337
  detail: message,
324
338
  requestId,
325
339
  issues: [{ path: [], message }],
340
+ ...batchDetails(error),
326
341
  },
327
342
  };
328
343
  }
@@ -421,6 +436,7 @@ export function expectedFailure(error, requestId) {
421
436
  requestId,
422
437
  ...(typeof entity === "string" ? { entity } : {}),
423
438
  ...(typeof id === "string" ? { id } : {}),
439
+ ...batchDetails(error),
424
440
  },
425
441
  };
426
442
  }
@@ -434,6 +450,7 @@ export function expectedFailure(error, requestId) {
434
450
  requestId,
435
451
  ...(typeof entity === "string" ? { entity } : {}),
436
452
  ...(typeof property === "string" ? { property } : {}),
453
+ ...batchDetails(error),
437
454
  },
438
455
  };
439
456
  }
@@ -1,6 +1,7 @@
1
1
  import { BlobValue } from "../blob.js";
2
2
  import { Schema, SchemaTransformation } from "effect";
3
3
  import { wireStruct } from "../wire.js";
4
+ import { BatchErrorDetails } from "../errors.js";
4
5
  import { AGGREGATE_FILTER_OPERATORS, ENTITY_AGGREGATE_FILTER_OPERATORS, filterValuesIssue, MAXIMUM_FILTER_VALUES, } from "../aggregate-filters.js";
5
6
  import { isValueMatched, OntologyManifestWire } from "../manifest.js";
6
7
  import { entityQuerySchemas, queryLinks } from "../query.js";
@@ -510,14 +511,17 @@ export const ErrorSchemas = {
510
511
  notFound: errorBody("NotFound", 404, ["not_found"], {
511
512
  entity: Schema.optionalKey(Schema.String),
512
513
  id: Schema.optionalKey(Schema.String),
514
+ batch: Schema.optionalKey(BatchErrorDetails),
513
515
  }),
514
516
  conflict: errorBody("Conflict", 409, ["conflict"], {
515
517
  entity: Schema.optionalKey(Schema.String),
516
518
  property: Schema.optionalKey(Schema.String),
519
+ batch: Schema.optionalKey(BatchErrorDetails),
517
520
  }),
518
521
  unsupportedMediaType: errorBody("UnsupportedMediaType", 415, ["unsupported_media_type"]),
519
522
  unprocessable: errorBody("Unprocessable", 422, ["invalid_input", "action_rejected"], {
520
523
  issues: Schema.Array(ValidationIssue),
524
+ batch: Schema.optionalKey(BatchErrorDetails),
521
525
  }),
522
526
  rateLimited: errorBody("RateLimited", 429, ["rate_limited"]),
523
527
  internal: errorBody("InternalError", 500, ["internal_error"]),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@segmentstream-cloud/ontology",
3
- "version": "0.25.1",
3
+ "version": "0.26.0",
4
4
  "description": "Define a SegmentStream workspace app's ontology: entities, relationships, roles and actions, written with Effect.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "SegmentStream",
@@ -45,7 +45,9 @@
45
45
  "lint": "eslint src tests --config ../../eslint.config.mjs",
46
46
  "test": "vitest run",
47
47
  "test:postgres": "vitest run --config vitest.postgres.config.ts",
48
- "prepack": "npm run build"
48
+ "prepack": "npm run build",
49
+ "benchmark:batch": "tsx tests/batch/benchmark.ts",
50
+ "benchmark:batch:profile": "tsx tests/batch/profile.ts"
49
51
  },
50
52
  "dependencies": {
51
53
  "@aws-sdk/client-s3": "^3.1148.0",