@segmentstream-cloud/ontology 0.25.0 → 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.
package/README.md CHANGED
@@ -253,6 +253,84 @@ export default defineOntology({
253
253
  `InvalidOntology` with every diagnostic when the definition has errors. Entities,
254
254
  actions and select options need a description.
255
255
 
256
+ ## Batch writes in actions
257
+
258
+ Stored entity handles expose `createMany(records, options?)`,
259
+ `updateMany([{ id, values }], options?)`, and `upsertMany({ on, records, returning? })`
260
+ inside authored action handlers. The action remains the permission and commit
261
+ boundary; a batch does not run other authored actions for its rows.
262
+
263
+ For example, an action can write sales plan lines by a declared compound unique
264
+ key:
265
+
266
+ ```ts
267
+ const PlanLine = entity("planLine", {
268
+ id: "ent_plan_line",
269
+ label: "Plan line",
270
+ plural: "Plan lines",
271
+ title: "planCode",
272
+ description: "A sales plan decision for a period.",
273
+ properties: {
274
+ planCode: p.text({ description: "The plan code." }),
275
+ period: p.date({ description: "The period start." }),
276
+ target: p.number({ description: "The agreed target." }),
277
+ },
278
+ indexes: [{ properties: ["planCode", "period"], unique: true }],
279
+ }).actions((self) => ({
280
+ setTargets: action({
281
+ description: "Set targets for several plan lines together.",
282
+ input: Schema.Struct({
283
+ lines: Schema.Array(Schema.Struct(self.properties)),
284
+ }),
285
+ handler: ({ input }) =>
286
+ self.upsertMany({
287
+ on: ["planCode", "period"],
288
+ records: input.lines,
289
+ returning: "summary",
290
+ }),
291
+ }),
292
+ }));
293
+ ```
294
+
295
+ Both result modes return `{ created, updated, unchanged }`, whose counts sum to
296
+ the input length. The default, `returning: "records"`, also returns `items` in
297
+ input order: `{ index, status, record }`, with `status` equal to `"created"`,
298
+ `"updated"`, or `"unchanged"`. `returning: "summary"` omits `items`; the enclosing
299
+ action still returns its normal `edits`. These types are exported as
300
+ `BatchRecordResult<Record>`, `BatchSummaryResult`, and `BatchResult<Record>`.
301
+
302
+ Creates retain normal defaults and validation. Updates patch distinct IDs:
303
+ omitted fields remain unchanged, and explicit `null` follows the property's
304
+ nullability. An unchanged patch preserves timestamps and attribution and adds no
305
+ history or replication work. Upsert keys must be a nonempty list of ordinary
306
+ properties matching a declared unique property or compound unique index. Each
307
+ input explicitly supplies non-null key values and enough values to create a
308
+ record; existing records receive only supplied non-key fields. Duplicate IDs or
309
+ upsert keys fail the batch.
310
+
311
+ Batch values support ordinary properties and stored `rel.one` assignments. Blob
312
+ and `rel.many` assignments are rejected; use the existing single-record methods
313
+ for those fields. Warehouse-backed and platform-owned built-in entities are not
314
+ batch write targets. `OntologyWrites` exposes the same operations with an
315
+ explicit `entity` and snake_case field names; entity handles convert authored
316
+ camelCase names and select values.
317
+
318
+ Each call accepts at most 200 records and 2 MiB of UTF-8 JSON input, including
319
+ record values, update IDs, and upsert key fields. One action may attempt at most
320
+ 500 rows across nonempty batch calls, including failed calls its handler catches.
321
+ Empty input returns zero counts and, in record mode, empty `items`, without SQL.
322
+ Every nonempty batch uses one savepoint inside the action transaction. A failure
323
+ rolls back all of that batch's records, history, replication references, and
324
+ edits; a handler may catch it and continue. A later uncaught action failure also
325
+ rolls back earlier successful batches.
326
+
327
+ Existing `ActionRejected`, `Conflict`, and `NotFound` errors may include
328
+ `batch: { indices, fields? }`: zero-based input positions and safe wire field
329
+ names. Batch calls are never automatically retried. Checkpoint a loader only
330
+ after confirmed action success; replay after a lost response is safe only when
331
+ the complete action is explicitly idempotent, including its other writes and
332
+ side effects. Generic upsert alone does not establish that guarantee.
333
+
256
334
  ## Permissions
257
335
 
258
336
  A permission is `<entity>:<verb>`. The verbs are `read`, the generated `create`, `update`,
package/dist/entity.d.ts CHANGED
@@ -3,10 +3,10 @@ import { Effect, type Schema } from "effect";
3
3
  import type { Measures } from "./measure.js";
4
4
  import type { WarehouseSource } from "./source.js";
5
5
  import type { Actions } from "./action.js";
6
- import type { Conflict, NotFound } from "./errors.js";
6
+ import type { ActionRejected, Conflict, NotFound } from "./errors.js";
7
7
  import { type AnyProperty } from "./property.js";
8
8
  import type { AnyRelationship, ManyRelationship, OneRelationship } from "./relationship.js";
9
- import { OntologyReads, OntologyWrites, type FindOptions, type GetOptions, type UpsertResult } from "./runtime.js";
9
+ import { OntologyReads, OntologyWrites, type BatchOptions, type BatchRecordResult, type BatchResult, type BatchSummaryResult, type FindOptions, type GetOptions, type UpsertResult } from "./runtime.js";
10
10
  export declare const EntityTypeId: unique symbol;
11
11
  export type Properties = {
12
12
  readonly [name: string]: AnyProperty;
@@ -98,6 +98,33 @@ export type UpdateValues<P, R> = Simplify<{
98
98
  } & {
99
99
  readonly [K in ManyKeys<R>]?: ReadonlyArray<string>;
100
100
  }>;
101
+ type BlobKeys<P> = {
102
+ [K in keyof P]-?: NonNullable<ValueOf<P[K]>> extends BlobValue ? K : never;
103
+ }[keyof P] & string;
104
+ type UnsupportedBatchKeys<P, R> = BlobKeys<P> | ManyKeys<R>;
105
+ /** Create values for a batch: ordinary properties and stored `rel.one` assignments only. */
106
+ export type BatchCreateValues<P, R> = Simplify<Omit<CreateValues<P, R>, UnsupportedBatchKeys<P, R>> & {
107
+ readonly [K in UnsupportedBatchKeys<P, R>]?: never;
108
+ }>;
109
+ /** Patch values for a batch. Blob and collection assignments use single-record methods. */
110
+ export type BatchUpdateValues<P, R> = Simplify<Omit<UpdateValues<P, R>, UnsupportedBatchKeys<P, R>> & {
111
+ readonly [K in UnsupportedBatchKeys<P, R>]?: never;
112
+ }>;
113
+ /** Ordinary declared properties accepted as upsert keys; unique declarations are checked at runtime. */
114
+ export type BatchKey<P> = Exclude<keyof P & string, BlobKeys<P>>;
115
+ /** Upsert inputs must explicitly supply non-null values for every selected conflict key. */
116
+ export type BatchUpsertValues<P, R, K extends BatchKey<P>> = Simplify<Omit<BatchCreateValues<P, R>, K> & {
117
+ readonly [Key in K]: NonNullable<WriteValueOf<P[Key]>>;
118
+ }>;
119
+ export interface BatchUpdateRecord<P, R> {
120
+ readonly id: string;
121
+ readonly values: BatchUpdateValues<P, R>;
122
+ }
123
+ interface EntityUpsertManyInput<P, R, K extends BatchKey<P>> extends BatchOptions {
124
+ readonly on: readonly [K, ...K[]];
125
+ readonly records: ReadonlyArray<BatchUpsertValues<P, R, NoInfer<K>>>;
126
+ }
127
+ type BatchWriteError = ActionRejected | Conflict | NotFound;
101
128
  /**
102
129
  * Fields an index may cover: declared properties, system properties and `rel.one` relationships
103
130
  * between stored entities.
@@ -172,6 +199,30 @@ export interface Entity<Name extends string = string, P extends Properties = Pro
172
199
  create(values: CreateValues<P, R>): Effect.Effect<EntityRecord<P, R>, Conflict | NotFound, OntologyWrites>;
173
200
  update(record: RecordRef, values: UpdateValues<P, R>): Effect.Effect<EntityRecord<P, R>, Conflict | NotFound, OntologyWrites>;
174
201
  upsertBy(property: UniqueKeys<P>, values: CreateValues<P, R>): Effect.Effect<UpsertResult<EntityRecord<P, R>>, Conflict | NotFound, OntologyWrites>;
202
+ /** Atomically create records. Defaults to returning one record result per input, in order. */
203
+ createMany(records: ReadonlyArray<BatchCreateValues<P, R>>, options: {
204
+ readonly returning: "summary";
205
+ }): Effect.Effect<BatchSummaryResult, BatchWriteError, OntologyWrites>;
206
+ createMany(records: ReadonlyArray<BatchCreateValues<P, R>>, options?: {
207
+ readonly returning?: "records";
208
+ }): Effect.Effect<BatchRecordResult<EntityRecord<P, R>>, BatchWriteError, OntologyWrites>;
209
+ createMany(records: ReadonlyArray<BatchCreateValues<P, R>>, options?: BatchOptions): Effect.Effect<BatchResult<EntityRecord<P, R>>, BatchWriteError, OntologyWrites>;
210
+ /** Atomically patch distinct record IDs; values equal to the locked record remain unchanged. */
211
+ updateMany(records: ReadonlyArray<BatchUpdateRecord<P, R>>, options: {
212
+ readonly returning: "summary";
213
+ }): Effect.Effect<BatchSummaryResult, BatchWriteError, OntologyWrites>;
214
+ updateMany(records: ReadonlyArray<BatchUpdateRecord<P, R>>, options?: {
215
+ readonly returning?: "records";
216
+ }): Effect.Effect<BatchRecordResult<EntityRecord<P, R>>, BatchWriteError, OntologyWrites>;
217
+ updateMany(records: ReadonlyArray<BatchUpdateRecord<P, R>>, options?: BatchOptions): Effect.Effect<BatchResult<EntityRecord<P, R>>, BatchWriteError, OntologyWrites>;
218
+ /** Atomically insert or patch by a declared unique property or compound unique index. */
219
+ upsertMany<const K extends BatchKey<P>>(input: EntityUpsertManyInput<P, R, K> & {
220
+ readonly returning: "summary";
221
+ }): Effect.Effect<BatchSummaryResult, BatchWriteError, OntologyWrites>;
222
+ upsertMany<const K extends BatchKey<P>>(input: EntityUpsertManyInput<P, R, K> & {
223
+ readonly returning?: "records";
224
+ }): Effect.Effect<BatchRecordResult<EntityRecord<P, R>>, BatchWriteError, OntologyWrites>;
225
+ upsertMany<const K extends BatchKey<P>>(input: EntityUpsertManyInput<P, R, K>): Effect.Effect<BatchResult<EntityRecord<P, R>>, BatchWriteError, OntologyWrites>;
175
226
  delete(record: RecordRef): Effect.Effect<void, Conflict | NotFound, OntologyWrites>;
176
227
  link(relationship: ManyKeys<R>, record: RecordRef, target: RecordRef): Effect.Effect<void, NotFound, OntologyWrites>;
177
228
  unlink(relationship: ManyKeys<R>, record: RecordRef, target: RecordRef): Effect.Effect<void, NotFound, OntologyWrites>;
package/dist/entity.js CHANGED
@@ -19,6 +19,34 @@ export function entity(name, definition) {
19
19
  measures: definition.measures ?? {},
20
20
  };
21
21
  }
22
+ const batchFromWire = (result) => "items" in result
23
+ ? {
24
+ ...result,
25
+ items: result.items.map((item) => ({
26
+ ...item,
27
+ record: recordFromWire(item.record, definition),
28
+ })),
29
+ }
30
+ : result;
31
+ const createMany = ((records, options) => withWrites((runtime) => runtime.createMany({
32
+ entity: toSnakeCase(name),
33
+ records: records.map((values) => valuesToWire(values, definition)),
34
+ ...(options?.returning === undefined ? {} : { returning: options.returning }),
35
+ })).pipe(Effect.map(batchFromWire)));
36
+ const updateMany = ((records, options) => withWrites((runtime) => runtime.updateMany({
37
+ entity: toSnakeCase(name),
38
+ records: records.map(({ id, values }) => ({
39
+ id,
40
+ values: valuesToWire(values, definition),
41
+ })),
42
+ ...(options?.returning === undefined ? {} : { returning: options.returning }),
43
+ })).pipe(Effect.map(batchFromWire)));
44
+ const upsertMany = ((input) => withWrites((runtime) => runtime.upsertMany({
45
+ entity: toSnakeCase(name),
46
+ on: input.on.map(toSnakeCase),
47
+ records: input.records.map((values) => valuesToWire(values, definition)),
48
+ ...(input.returning === undefined ? {} : { returning: input.returning }),
49
+ })).pipe(Effect.map(batchFromWire)));
22
50
  const handle = {
23
51
  [EntityTypeId]: EntityTypeId,
24
52
  name,
@@ -26,6 +54,9 @@ export function entity(name, definition) {
26
54
  properties: definition.properties,
27
55
  relationships: definition.relationships ?? {},
28
56
  ref: p.ref(name),
57
+ createMany,
58
+ updateMany,
59
+ upsertMany,
29
60
  get: (record, options) => withReads((runtime) => runtime.get({
30
61
  entity: toSnakeCase(name),
31
62
  id: idOf(record),
package/dist/errors.d.ts CHANGED
@@ -1,6 +1,16 @@
1
1
  import { Schema } from "effect";
2
+ /** Safe batch failure locations: zero-based input indices and wire field names. */
3
+ export declare const BatchErrorDetails: Schema.Struct<{
4
+ readonly indices: Schema.$Array<Schema.Int>;
5
+ readonly fields: Schema.optionalKey<Schema.$Array<Schema.String>>;
6
+ }>;
7
+ export type BatchErrorDetails = typeof BatchErrorDetails.Type;
2
8
  declare const ActionRejected_base: Schema.Class<ActionRejected, Schema.TaggedStruct<"ActionRejected", {
3
9
  readonly message: Schema.String;
10
+ readonly batch: Schema.optionalKey<Schema.Struct<{
11
+ readonly indices: Schema.$Array<Schema.Int>;
12
+ readonly fields: Schema.optionalKey<Schema.$Array<Schema.String>>;
13
+ }>>;
4
14
  }>, import("effect/Cause").YieldableError>;
5
15
  /**
6
16
  * An action refused the request for a business reason. The message is written for the caller and is
@@ -23,6 +33,10 @@ declare const Conflict_base: Schema.Class<Conflict, Schema.TaggedStruct<"Conflic
23
33
  readonly entity: Schema.String;
24
34
  readonly message: Schema.String;
25
35
  readonly property: Schema.optionalKey<Schema.String>;
36
+ readonly batch: Schema.optionalKey<Schema.Struct<{
37
+ readonly indices: Schema.$Array<Schema.Int>;
38
+ readonly fields: Schema.optionalKey<Schema.$Array<Schema.String>>;
39
+ }>>;
26
40
  }>, import("effect/Cause").YieldableError>;
27
41
  /** The write conflicts with existing data, for example a duplicate value of a unique property. */
28
42
  export declare class Conflict extends Conflict_base {
@@ -31,6 +45,10 @@ declare const NotFound_base: Schema.Class<NotFound, Schema.TaggedStruct<"NotFoun
31
45
  readonly entity: Schema.String;
32
46
  readonly id: Schema.String;
33
47
  readonly message: Schema.String;
48
+ readonly batch: Schema.optionalKey<Schema.Struct<{
49
+ readonly indices: Schema.$Array<Schema.Int>;
50
+ readonly fields: Schema.optionalKey<Schema.$Array<Schema.String>>;
51
+ }>>;
34
52
  }>, import("effect/Cause").YieldableError>;
35
53
  /** The record does not exist. */
36
54
  export declare class NotFound extends NotFound_base {
package/dist/errors.js CHANGED
@@ -1,10 +1,16 @@
1
1
  import { Schema } from "effect";
2
+ /** Safe batch failure locations: zero-based input indices and wire field names. */
3
+ export const BatchErrorDetails = Schema.Struct({
4
+ indices: Schema.Array(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),
5
+ fields: Schema.optionalKey(Schema.Array(Schema.String)),
6
+ });
2
7
  /**
3
8
  * An action refused the request for a business reason. The message is written for the caller and is
4
9
  * returned to them.
5
10
  */
6
11
  export class ActionRejected extends Schema.TaggedError()("ActionRejected", {
7
12
  message: Schema.String,
13
+ batch: Schema.optionalKey(BatchErrorDetails),
8
14
  }) {
9
15
  }
10
16
  /**
@@ -22,6 +28,7 @@ export class Conflict extends Schema.TaggedError()("Conflict", {
22
28
  entity: Schema.String,
23
29
  message: Schema.String,
24
30
  property: Schema.optionalKey(Schema.String),
31
+ batch: Schema.optionalKey(BatchErrorDetails),
25
32
  }) {
26
33
  }
27
34
  /** The record does not exist. */
@@ -29,5 +36,6 @@ export class NotFound extends Schema.TaggedError()("NotFound", {
29
36
  entity: Schema.String,
30
37
  id: Schema.String,
31
38
  message: Schema.String,
39
+ batch: Schema.optionalKey(BatchErrorDetails),
32
40
  }) {
33
41
  }
package/dist/index.d.ts CHANGED
@@ -13,8 +13,8 @@ export type { EntityPermissions, JsonSchemaObject, MemberOptions, OntologyConfig
13
13
  export { Diagnostic, DIAGNOSTIC_CODES, formatDiagnostics, InvalidOntology } from "./diagnostics.js";
14
14
  export type { DiagnosticCode } from "./diagnostics.js";
15
15
  export { entity, isEntity } from "./entity.js";
16
- export type { ActionsBuilder, AnyEntity, AnyStoredEntity, CreateValues, DraftEntity, Entity, EntityDefinition, EntityRecord, IndexDefinition, IndexField, NoActions, Properties, RecordRef, Relationships, SystemFields, UpdateValues, } from "./entity.js";
17
- export { ActionRejected, Conflict, NotFound, PermissionDenied } from "./errors.js";
16
+ export type { ActionsBuilder, AnyEntity, AnyStoredEntity, BatchCreateValues, BatchKey, BatchUpdateRecord, BatchUpdateValues, BatchUpsertValues, CreateValues, DraftEntity, Entity, EntityDefinition, EntityRecord, IndexDefinition, IndexField, NoActions, Properties, RecordRef, Relationships, SystemFields, UpdateValues, } from "./entity.js";
17
+ export { ActionRejected, BatchErrorDetails, Conflict, NotFound, PermissionDenied, } from "./errors.js";
18
18
  export type { ActionError } from "./errors.js";
19
19
  export { ActionManifest, actionConnectionKeys, canonicalJson, ConnectionManifest, EntityManifest, EntityPermissionsManifest, decodeManifestDocument, encodeManifest, formatManifest, IndexManifest, isValueMatched, MANIFEST_VERSION, manifestHash, ModelMeasureManifest, OntologyManifest, OntologyManifestWire, PropertyManifest, RelationshipManifest, RoleManifest, SelectOptionManifest, SignInManifest, signInPolicyOf, } from "./manifest.js";
20
20
  export { SYSTEM_PROPERTIES } from "./names.js";
@@ -34,7 +34,7 @@ export type { Role, RoleOptions } from "./role.js";
34
34
  export { DEFAULT_SIGN_IN_POLICY, SIGN_IN_METHODS, SIGN_IN_POLICIES } from "./sign-in.js";
35
35
  export type { SignInCheck, SignInMethod, SignInOptions, SignInPolicy } from "./sign-in.js";
36
36
  export { OntologyReads, OntologyRuntime, OntologyWrites } from "./runtime.js";
37
- export type { FieldCondition, OntologyRuntimeService, FindOptions, GetOptions, Principal, RecordWhere, StoredRecord, StoredValues, UpsertResult, } from "./runtime.js";
37
+ export type { BatchOptions, BatchRecordResult, BatchResult, BatchReturning, BatchSummaryResult, CreateManyInput, FieldCondition, OntologyRuntimeService, FindOptions, GetOptions, Principal, RecordWhere, StoredRecord, StoredValues, UpsertResult, UpdateManyInput, UpsertManyInput, } from "./runtime.js";
38
38
  export { User } from "./user.js";
39
39
  export { AgentChannel, AgentChat } from "./agent-chat.js";
40
40
  export type { AgentMessage } from "./agent-chat.js";
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ export { action, isAction } from "./action.js";
6
6
  export { CONNECTION_ERROR_REASONS, CONNECTION_KEY, CONNECTION_METHODS, connection, ConnectionError, Connections, ConnectionTransport, isConnection, MAXIMUM_CONNECTION_BODY_BYTES, } from "./connection.js";
7
7
  export { Diagnostic, DIAGNOSTIC_CODES, formatDiagnostics, InvalidOntology } from "./diagnostics.js";
8
8
  export { entity, isEntity } from "./entity.js";
9
- export { ActionRejected, Conflict, NotFound, PermissionDenied } from "./errors.js";
9
+ export { ActionRejected, BatchErrorDetails, Conflict, NotFound, PermissionDenied, } from "./errors.js";
10
10
  export { ActionManifest, actionConnectionKeys, canonicalJson, ConnectionManifest, EntityManifest, EntityPermissionsManifest, decodeManifestDocument, encodeManifest, formatManifest, IndexManifest, isValueMatched, MANIFEST_VERSION, manifestHash, ModelMeasureManifest, OntologyManifest, OntologyManifestWire, PropertyManifest, RelationshipManifest, RoleManifest, SelectOptionManifest, SignInManifest, signInPolicyOf, } from "./manifest.js";
11
11
  export { SYSTEM_PROPERTIES } from "./names.js";
12
12
  export { check, defineOntology } from "./ontology.js";
@@ -0,0 +1,22 @@
1
+ import { Effect } from "effect";
2
+ import { type SqlError } from "effect/unstable/sql/SqlError";
3
+ import type { EntityModel } from "./model.js";
4
+ interface CauseDiagnostic {
5
+ readonly tag: string;
6
+ readonly message: string;
7
+ readonly messageRedacted: boolean;
8
+ readonly stack?: string;
9
+ }
10
+ /** A safe projection of every wrapper; the original driver cause stays on the Conflict itself. */
11
+ export declare function batchConflictDiagnostic(entity: EntityModel, error: SqlError): {
12
+ causeChain: CauseDiagnostic[];
13
+ constraint?: string | undefined;
14
+ sqlState: string;
15
+ };
16
+ /** Emit before mapping the error, so even a handler-caught conflict has a correlated diagnostic. */
17
+ export declare function logBatchConflict(entity: EntityModel, error: SqlError, scope: {
18
+ readonly operation: string;
19
+ readonly action: string;
20
+ readonly actionId: string;
21
+ }): Effect.Effect<void>;
22
+ export {};
@@ -0,0 +1,89 @@
1
+ import { Effect } from "effect";
2
+ import { isSqlError, isSqlErrorReason } from "effect/unstable/sql/SqlError";
3
+ import { fieldColumnName, indexName, primaryKeyName, uniqueIndexName } from "../migrate/storage.js";
4
+ const DRIVER_MESSAGES = new Set([
5
+ "PgConnection: Query failed",
6
+ "PgConnection: The server reported an error",
7
+ ]);
8
+ const FRAME_PREFIX = "^\\s+at (?:(?:async |new )?(?:[\\w.$<>]+ )?(?:\\[as [\\w$]+\\] )?\\()?";
9
+ const FRAME_LOCATION = "(?:(?:file:\\/\\/)?\\/[\\w./@%+~:-]+|node:[\\w./-]+)";
10
+ const SOURCE_FRAME = new RegExp(`${FRAME_PREFIX}${FRAME_LOCATION}:\\d+:\\d+\\)?$`);
11
+ function knownConstraint(entity, error) {
12
+ const cause = error.reason.cause;
13
+ const constraint = error.reason._tag === "UniqueViolation"
14
+ ? error.reason.constraint
15
+ : typeof cause === "object" && cause !== null && "constraint" in cause
16
+ ? cause.constraint
17
+ : undefined;
18
+ const names = [
19
+ primaryKeyName(entity.table),
20
+ primaryKeyName(entity.historyTable),
21
+ ...entity.manifest.properties
22
+ .filter((field) => field.unique)
23
+ .map((field) => uniqueIndexName(entity.name, field.name)),
24
+ ...entity.manifest.indexes
25
+ .filter((index) => index.unique)
26
+ .map((index) => indexName(entity.table, index.properties.map((name) => fieldColumnName(entity.manifest, name) ?? name))),
27
+ ];
28
+ // PostgreSQL truncates these generated ASCII identifiers to its 63-byte identifier limit.
29
+ return typeof constraint === "string" && names.some((name) => name.slice(0, 63) === constraint)
30
+ ? constraint
31
+ : undefined;
32
+ }
33
+ function causeTag(cause) {
34
+ if (isSqlError(cause)) {
35
+ return "SqlError";
36
+ }
37
+ if (isSqlErrorReason(cause)) {
38
+ return cause._tag;
39
+ }
40
+ return cause instanceof Error ? "Error" : "UnknownCause";
41
+ }
42
+ function safeCause(cause, constraint) {
43
+ if (!(cause instanceof Error)) {
44
+ return { tag: causeTag(cause), message: "[redacted database cause]", messageRedacted: true };
45
+ }
46
+ const original = cause.message;
47
+ const knownUniqueMessage = constraint === undefined
48
+ ? undefined
49
+ : `duplicate key value violates unique constraint "${constraint}"`;
50
+ const allowed = DRIVER_MESSAGES.has(original) || original === knownUniqueMessage;
51
+ const message = allowed ? original : "[redacted database message]";
52
+ const headline = `${cause.name}: ${original}`;
53
+ const stack = typeof cause.stack === "string" && cause.stack.startsWith(`${headline}\n`)
54
+ ? cause.stack.slice(headline.length + 1)
55
+ : "";
56
+ // Remove the complete (possibly multiline) original headline before keeping JS source frames.
57
+ // SQL, hints, parameters and arbitrary database explanations never enter this projection.
58
+ const frames = stack.split("\n").filter((line) => SOURCE_FRAME.test(line));
59
+ return {
60
+ tag: causeTag(cause),
61
+ message,
62
+ messageRedacted: !allowed,
63
+ ...(frames.length > 0 ? { stack: frames.join("\n") } : {}),
64
+ };
65
+ }
66
+ /** A safe projection of every wrapper; the original driver cause stays on the Conflict itself. */
67
+ export function batchConflictDiagnostic(entity, error) {
68
+ const constraint = knownConstraint(entity, error);
69
+ const causeChain = [];
70
+ const seen = new Set();
71
+ let current = error;
72
+ while (current !== undefined && !seen.has(current)) {
73
+ seen.add(current);
74
+ causeChain.push(safeCause(current, constraint));
75
+ current =
76
+ typeof current === "object" && current !== null && "cause" in current
77
+ ? current.cause
78
+ : undefined;
79
+ }
80
+ return { sqlState: "23505", ...(constraint === undefined ? {} : { constraint }), causeChain };
81
+ }
82
+ /** Emit before mapping the error, so even a handler-caught conflict has a correlated diagnostic. */
83
+ export function logBatchConflict(entity, error, scope) {
84
+ return Effect.logWarning("ontology.action.batch_conflict").pipe(Effect.annotateLogs({
85
+ ...scope,
86
+ entity: entity.name,
87
+ ...batchConflictDiagnostic(entity, error),
88
+ }));
89
+ }
@@ -0,0 +1,48 @@
1
+ import { Effect } from "effect";
2
+ import type { SqlError } from "effect/unstable/sql/SqlError";
3
+ import { ActionRejected, Conflict, NotFound } from "../errors.js";
4
+ import type { BatchResult, BatchReturning, StoredValues } from "../runtime.js";
5
+ import { type EntityModel, type FieldModel } from "./model.js";
6
+ import { type PreparedWrite } from "./records.js";
7
+ import type { WriteContext } from "./writes.js";
8
+ export declare const BATCH_MAX_ROWS = 200;
9
+ export declare const BATCH_MAX_BYTES: number;
10
+ export declare const ACTION_MAX_BATCH_ROWS = 500;
11
+ /** The pinned PostgreSQL driver's signed Int16 count caps bindings at 32,767. */
12
+ export declare const BATCH_MAX_PARAMETERS = 30000;
13
+ /** Includes bound values and an allowance for their SQL placeholders. */
14
+ export declare const BATCH_MAX_STATEMENT_BYTES: number;
15
+ export type BatchOperation = "create" | "update" | "upsert";
16
+ export type BatchError = ActionRejected | Conflict | NotFound | SqlError;
17
+ export interface BatchInput {
18
+ readonly records: ReadonlyArray<StoredValues | {
19
+ readonly id: string;
20
+ readonly values: StoredValues;
21
+ }>;
22
+ readonly returning?: BatchReturning;
23
+ readonly on?: ReadonlyArray<string>;
24
+ }
25
+ interface PlannedRow {
26
+ readonly index: number;
27
+ readonly id: string;
28
+ readonly create: PreparedWrite;
29
+ readonly patch: PreparedWrite;
30
+ }
31
+ export interface BatchPlan {
32
+ readonly operation: BatchOperation;
33
+ readonly returning: BatchReturning;
34
+ readonly rows: ReadonlyArray<PlannedRow>;
35
+ readonly keys: ReadonlyArray<FieldModel>;
36
+ readonly distinctReferences: number;
37
+ }
38
+ export interface BatchContext extends WriteContext {
39
+ /** Set only after a prior write's savepoint succeeded. */
40
+ readonly attributionInitialized: boolean;
41
+ }
42
+ /** One UTF-8 JSON encoding defines the public byte bound, including IDs and conflict fields. */
43
+ export declare function batchInputBytes(input: BatchInput): number;
44
+ /** All authored-value validation finishes before a record mutation can execute. */
45
+ export declare function planBatch(entity: EntityModel, operation: BatchOperation, input: BatchInput): Effect.Effect<BatchPlan, ActionRejected | NotFound>;
46
+ /** Executes one set of mutations; the caller owns its single savepoint and edit publication. */
47
+ export declare function executeBatch(context: BatchContext, entity: EntityModel, plan: BatchPlan): Effect.Effect<BatchResult, BatchError>;
48
+ export {};