@vibeorm/generator 2.0.1 → 2.1.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/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import { join } from "path";
5
5
  import { buildComputedManifest, validateComputedConfig } from "@vibeorm/schema";
6
6
 
7
7
  // packages/generator/src/emit-dts.ts
8
- import { VibeError as VibeError3 } from "@vibeorm/schema";
8
+ import { DIALECT_CAPABILITIES, VibeError as VibeError3, providerToDialect as providerToDialect2 } from "@vibeorm/schema";
9
9
 
10
10
  // packages/generator/src/emit-js.ts
11
11
  import { internalError } from "@vibeorm/schema";
@@ -24,8 +24,10 @@ var EMPTY_BAKED_EXTENSIONS = {
24
24
  manifest: [],
25
25
  dtsImports: [],
26
26
  delegateDts: new Map,
27
+ readOnlyDelegateDts: new Map,
27
28
  moduleDts: [],
28
29
  clientDts: [],
30
+ readOnlyClientDts: [],
29
31
  empty: true
30
32
  };
31
33
 
@@ -46,6 +48,37 @@ class ImportCollector {
46
48
  function indent(lines) {
47
49
  return lines.map((line) => line.length === 0 ? line : ` ${line}`);
48
50
  }
51
+ function readOnlyClassification(params) {
52
+ const { extension, contribution } = params;
53
+ const readOnly = contribution.readOnly;
54
+ if (readOnly === undefined)
55
+ return;
56
+ const refuse = (problem) => {
57
+ throw new VibeError({
58
+ code: "VIBE_CONFIG",
59
+ message: `extension "${extension.name}": readOnly ${problem}`,
60
+ meta: { extension: extension.name, kind: contribution.kind }
61
+ });
62
+ };
63
+ if (contribution.kind === "method" && readOnly !== true) {
64
+ refuse('must be `true` on a "method" contribution \u2014 a member list classifies a namespace');
65
+ }
66
+ if (contribution.kind === "namespace") {
67
+ if (readOnly === true) {
68
+ refuse('must list the readable members on a "namespace" contribution \u2014 `true` would also classify members added later');
69
+ } else if (readOnly.length === 0) {
70
+ refuse("names no members \u2014 omit it instead, which denies the whole namespace");
71
+ }
72
+ }
73
+ return readOnly;
74
+ }
75
+ function readOnlyLine(params) {
76
+ const { owner, property, readOnly } = params;
77
+ if (readOnly === undefined)
78
+ return;
79
+ const access = `${owner}[${JSON.stringify(property)}]`;
80
+ return readOnly === true ? ` readonly ${property}: ${access};` : ` readonly ${property}: Pick<${access}, ${readOnly.map((member) => JSON.stringify(member)).join(" | ")}>;`;
81
+ }
49
82
  function bakeExtensions(params) {
50
83
  const { schema } = params;
51
84
  if (params.extensions.length === 0)
@@ -58,21 +91,33 @@ function bakeExtensions(params) {
58
91
  const dtsImports = new ImportCollector;
59
92
  const mounts = [];
60
93
  const delegateDts = new Map;
94
+ const readOnlyDelegateDts = new Map;
61
95
  const moduleDts = [];
62
96
  const clientDts = [];
97
+ const readOnlyClientDts = [];
63
98
  const useContribution = (paramsInner) => {
64
99
  const { extension, contribution, scope, model } = paramsInner;
65
100
  const property = scope === "model" ? extension.name : `$${extension.name}`;
101
+ const readOnly = readOnlyClassification({ extension, contribution });
66
102
  jsImports.add(contribution.imports);
67
103
  dtsImports.add(contribution.dtsImports);
68
104
  moduleDts.push(...contribution.dtsTypes ?? []);
69
- mounts.push(`{ extension: ${JSON.stringify(extension.name)}, scope: ${JSON.stringify(scope)}, ${model === undefined ? "" : `model: ${JSON.stringify(model)}, `}property: ${JSON.stringify(property)}, create: ${contribution.runtimeGlue} }`);
105
+ mounts.push(`{ extension: ${JSON.stringify(extension.name)}, scope: ${JSON.stringify(scope)}, ${model === undefined ? "" : `model: ${JSON.stringify(model)}, `}property: ${JSON.stringify(property)}, ${readOnly === undefined ? "" : `readOnly: ${JSON.stringify(readOnly)}, `}create: ${contribution.runtimeGlue} }`);
70
106
  if (scope === "model" && model !== undefined) {
71
107
  const lines = delegateDts.get(model) ?? [];
72
108
  lines.push(...indent(contribution.dts));
73
109
  delegateDts.set(model, lines);
110
+ const classified = readOnlyLine({ owner: `${model}Delegate`, property, readOnly });
111
+ if (classified !== undefined) {
112
+ const readOnlyLines = readOnlyDelegateDts.get(model) ?? [];
113
+ readOnlyLines.push(classified);
114
+ readOnlyDelegateDts.set(model, readOnlyLines);
115
+ }
74
116
  } else {
75
117
  clientDts.push(...indent(contribution.dts));
118
+ const classified = readOnlyLine({ owner: "VibeClientInstance", property, readOnly });
119
+ if (classified !== undefined)
120
+ readOnlyClientDts.push(classified);
76
121
  }
77
122
  };
78
123
  for (const extension of sorted) {
@@ -125,8 +170,10 @@ function bakeExtensions(params) {
125
170
  manifest: sorted.map((extension) => extensionManifestEntry({ extension })),
126
171
  dtsImports: dtsImports.lines({ typeOnly: true }),
127
172
  delegateDts,
173
+ readOnlyDelegateDts,
128
174
  moduleDts,
129
175
  clientDts,
176
+ readOnlyClientDts,
130
177
  empty: false
131
178
  };
132
179
  }
@@ -300,7 +347,8 @@ function emitJs(params) {
300
347
  const hasComputed = computed.length > 0;
301
348
  const parts = [];
302
349
  parts.push(HEADER);
303
- parts.push(`import { createClient } from "@vibeorm/runtime";`);
350
+ parts.push(`import { createClient, createSqlModel } from "@vibeorm/runtime";`);
351
+ parts.push(`export { sql, decode, sqlProjection } from "@vibeorm/runtime";`);
304
352
  if (baked.jsImports.length > 0)
305
353
  parts.push(baked.jsImports.join(`
306
354
  `));
@@ -309,6 +357,11 @@ function emitJs(params) {
309
357
  parts.push(`export { ${enumEntryExports({ view }).join(", ")} } from "./enums.js";`);
310
358
  parts.push(`/** The normalized Schema IR this client was generated from (plain data). */
311
359
  export const schema = ${serializeSchema({ schema: view.schema })};`);
360
+ parts.push(`/** Mapped identifiers and explicit projection decoders. */
361
+ export const sqlModels = Object.freeze({
362
+ ${view.models.map((model) => ` [${tsString({ value: model.clientName })}]: (options = {}) => createSqlModel({ schema, model: ${tsString({ value: model.name })}, ...options }),`).join(`
363
+ `)}
364
+ });`);
312
365
  if (baked.empty && !hasComputed) {
313
366
  parts.push(`/** Build a typed client over a database adapter. */
314
367
  ` + `export function VibeClient(options) {
@@ -434,6 +487,12 @@ function updateOpsName(params) {
434
487
  function banner(params) {
435
488
  return `// \u2500\u2500\u2500 ${params.title} \u2500\u2500\u2500`;
436
489
  }
490
+ function capabilitiesOf(params) {
491
+ return DIALECT_CAPABILITIES[providerToDialect2({ provider: params.view.schema.datasource.provider })];
492
+ }
493
+ function literalUnion(params) {
494
+ return params.members.map((member) => tsString({ value: member })).join(" | ");
495
+ }
437
496
  function objectAlias(params) {
438
497
  const { name, lines } = params;
439
498
  if (lines.length === 0)
@@ -1030,7 +1089,7 @@ ${model.relations.map((relation) => ` ${propKey({ name: relation.name })}: ${re
1030
1089
  const listRelations = model.relations.filter((relation) => relation.isList);
1031
1090
  const hasCounts = listRelations.length > 0;
1032
1091
  const countLine = ` readonly _count?: boolean | { readonly select: ${n}CountSelect };`;
1033
- const relationSelectValue = (relation) => `boolean | ${relation.target}${relation.isList ? "FindManyArgs" : "Args"}`;
1092
+ const relationSelectValue = (relation) => `boolean | ${relation.target}${relation.isList ? "RelationFindManyArgs" : "Args"}`;
1034
1093
  if (hasCounts) {
1035
1094
  parts.push(inputAlias({
1036
1095
  name: `${n}CountSelect`,
@@ -1082,6 +1141,8 @@ ${model.relations.map((relation) => ` ${propKey({ name: relation.name })}: ${re
1082
1141
  lines: [` readonly is?: ${n}WhereInput | null;`, ` readonly isNot?: ${n}WhereInput | null;`]
1083
1142
  }));
1084
1143
  const whereLines = [
1144
+ ` /** Constructed raw SQL; refused on scoped, read-only and field-masked handles. */`,
1145
+ ` readonly $raw?: SqlFragment;`,
1085
1146
  ` readonly AND?: ${orMany({ type: `${n}WhereInput` })};`,
1086
1147
  ` readonly OR?: readonly ${n}WhereInput[];`,
1087
1148
  ` readonly NOT?: ${orMany({ type: `${n}WhereInput` })};`
@@ -1119,9 +1180,18 @@ ${model.relations.map((relation) => ` ${propKey({ name: relation.name })}: ${re
1119
1180
  | ${members.join(`
1120
1181
  | `)};`);
1121
1182
  }
1183
+ const scalarOrderLines = model.fields.map((field) => ` readonly ${propKey({ name: field.name })}?: ${field.isOptional ? "SortOrderWithNulls" : "SortOrder"};`);
1184
+ parts.push(inputAlias({ name: `${n}ScalarOrderByInput`, lines: scalarOrderLines }));
1185
+ parts.push(inputAlias({
1186
+ name: `${n}RelationOrderByInput`,
1187
+ lines: model.fields.map((field) => ` readonly ${propKey({ name: field.name })}?: SortOrder;`)
1188
+ }));
1122
1189
  parts.push(inputAlias({
1123
1190
  name: `${n}OrderByInput`,
1124
- lines: model.fields.map((field) => ` readonly ${propKey({ name: field.name })}?: ${field.isOptional ? "SortOrderWithNulls" : "SortOrder"};`)
1191
+ lines: [
1192
+ ...scalarOrderLines,
1193
+ ...model.relations.map((relation) => ` readonly ${propKey({ name: relation.name })}?: ${relation.isList ? "{ readonly _count?: SortOrder | Skip }" : `${relation.target}RelationOrderByInput`};`)
1194
+ ]
1125
1195
  }));
1126
1196
  parts.push(inputAlias({ name: `${n}CreateInput`, lines: checkedCreateLines({ model }) }));
1127
1197
  parts.push(inputAlias({ name: `${n}UncheckedCreateInput`, lines: uncheckedCreateLines({ model }) }));
@@ -1157,24 +1227,28 @@ ${model.relations.map((relation) => ` ${propKey({ name: relation.name })}: ${re
1157
1227
  aggregateOpProps.push(` readonly _min?: ${n}MinAggregateInput;`);
1158
1228
  aggregateOpProps.push(` readonly _max?: ${n}MaxAggregateInput;`);
1159
1229
  }
1160
- parts.push(inputAlias({
1161
- name: `${n}FindManyArgs`,
1162
- lines: [
1163
- ` readonly where?: ${n}WhereInput;`,
1164
- ` readonly orderBy?: ${orMany({ type: `${n}OrderByInput` })};`,
1165
- ` readonly select?: ${n}Select;`,
1166
- ` readonly include?: ${n}Include;`,
1167
- ` readonly omit?: ${n}OmitInput;`,
1168
- ` readonly cursor?: ${n}WhereUniqueInput;`,
1169
- ` readonly after?: KeysetToken;`,
1170
- ` readonly keyset?: KeysetOptions;`,
1171
- ` readonly take?: number;`,
1172
- ` readonly skip?: number;`,
1173
- ` readonly distinct?: ${orMany({ type: `${n}ScalarFieldEnum` })};`,
1174
- ` readonly relationStrategy?: RelationStrategy;`,
1175
- ` readonly lock?: "update";`
1176
- ]
1177
- }));
1230
+ for (const nested of [false, true]) {
1231
+ parts.push(inputAlias({
1232
+ name: `${n}${nested ? "RelationFindManyArgs" : "FindManyArgs"}`,
1233
+ lines: [
1234
+ ` readonly where?: ${n}WhereInput;`,
1235
+ ` readonly orderBy?: ${orMany({ type: `${n}${nested ? "ScalarOrderByInput" : "OrderByInput"}` })};`,
1236
+ ` readonly select?: ${n}Select;`,
1237
+ ` readonly include?: ${n}Include;`,
1238
+ ` readonly omit?: ${n}OmitInput;`,
1239
+ ` readonly take?: number;`,
1240
+ ` readonly skip?: number;`,
1241
+ ...!nested ? [
1242
+ ` readonly cursor?: ${n}WhereUniqueInput;`,
1243
+ ` readonly after?: KeysetToken;`,
1244
+ ` readonly keyset?: KeysetOptions;`,
1245
+ ` readonly distinct?: ${orMany({ type: `${n}ScalarFieldEnum` })};`,
1246
+ ` readonly relationStrategy?: RelationStrategy;`,
1247
+ lockArgumentLines()
1248
+ ] : []
1249
+ ]
1250
+ }));
1251
+ }
1178
1252
  parts.push(`export type ${n}FindFirstArgs = ${n}FindManyArgs;`);
1179
1253
  parts.push(inputAlias({
1180
1254
  name: `${n}FindUniqueArgs`,
@@ -1184,12 +1258,13 @@ ${model.relations.map((relation) => ` ${propKey({ name: relation.name })}: ${re
1184
1258
  ` readonly include?: ${n}Include;`,
1185
1259
  ` readonly omit?: ${n}OmitInput;`,
1186
1260
  ` readonly relationStrategy?: RelationStrategy;`,
1187
- ` readonly lock?: "update";`
1261
+ lockArgumentLines()
1188
1262
  ]
1189
1263
  }));
1190
1264
  parts.push(inputAlias({
1191
1265
  name: `${n}CreateArgs`,
1192
1266
  lines: [
1267
+ ` readonly onConflict?: never;`,
1193
1268
  ` readonly data: ${n}CreateInput | ${n}UncheckedCreateInput;`,
1194
1269
  ` readonly select?: ${n}Select;`,
1195
1270
  ` readonly include?: ${n}Include;`,
@@ -1197,14 +1272,9 @@ ${model.relations.map((relation) => ` ${propKey({ name: relation.name })}: ${re
1197
1272
  ]
1198
1273
  }));
1199
1274
  parts.push(`export type ${n}ConflictTarget = readonly ${n}ScalarFieldEnum[] | { readonly constraint: string };`);
1200
- parts.push(inputAlias({
1201
- name: `${n}CreateOnConflictInput`,
1202
- lines: [
1203
- ` readonly action: "nothing";`,
1204
- ` readonly target?: ${n}ConflictTarget;`,
1205
- ` readonly where?: string;`
1206
- ]
1207
- }));
1275
+ parts.push(`export type ${n}CreateOnConflictInput =
1276
+ ` + ` | { readonly action: "nothing"; readonly update?: never; readonly target?: ${n}ConflictTarget | Skip; readonly where?: string | Skip }` + (capabilitiesOf({ view }).returning ? `
1277
+ | { readonly action: "update"; readonly target: ${n}ConflictTarget; readonly update: { ${model.fields.map((field) => `readonly ${propKey({ name: field.name })}?: SqlFragment`).join("; ")} }; readonly where?: string | Skip }` : "") + ";");
1208
1278
  parts.push(inputAlias({
1209
1279
  name: `${n}CreateOrSkipArgs`,
1210
1280
  lines: [
@@ -1237,7 +1307,7 @@ ${model.relations.map((relation) => ` ${propKey({ name: relation.name })}: ${re
1237
1307
  ` readonly data: readonly ${n}CreateManyInput[];`,
1238
1308
  ` readonly conflictTarget: ${n}ConflictTarget;`,
1239
1309
  ` readonly conflictWhere?: string;`,
1240
- ` readonly update: readonly ${n}ScalarFieldEnum[];`,
1310
+ ` readonly update: readonly ${n}ScalarFieldEnum[] | { ${model.fields.map((field) => `readonly ${propKey({ name: field.name })}?: SqlFragment`).join("; ")} };`,
1241
1311
  ` readonly skipUpdatedAt?: boolean;`,
1242
1312
  ` readonly allowAnyUniqueConflict?: boolean;`
1243
1313
  ]
@@ -1301,7 +1371,7 @@ export type ${n}UpdateWhereInput = ${n}WhereUniqueInput & ${n}WhereInput;`);
1301
1371
  lines: [
1302
1372
  ` readonly select?: ${n}CountAggregateInput;`,
1303
1373
  ` readonly where?: ${n}WhereInput;`,
1304
- ` readonly orderBy?: ${orMany({ type: `${n}OrderByInput` })};`,
1374
+ ` readonly orderBy?: ${orMany({ type: `${n}ScalarOrderByInput` })};`,
1305
1375
  ` readonly cursor?: ${n}WhereUniqueInput;`,
1306
1376
  ` readonly take?: number;`,
1307
1377
  ` readonly skip?: number;`
@@ -1311,7 +1381,7 @@ export type ${n}UpdateWhereInput = ${n}WhereUniqueInput & ${n}WhereInput;`);
1311
1381
  name: `${n}AggregateArgs`,
1312
1382
  lines: [
1313
1383
  ` readonly where?: ${n}WhereInput;`,
1314
- ` readonly orderBy?: ${orMany({ type: `${n}OrderByInput` })};`,
1384
+ ` readonly orderBy?: ${orMany({ type: `${n}ScalarOrderByInput` })};`,
1315
1385
  ` readonly cursor?: ${n}WhereUniqueInput;`,
1316
1386
  ` readonly take?: number;`,
1317
1387
  ` readonly skip?: number;`,
@@ -1323,7 +1393,7 @@ export type ${n}UpdateWhereInput = ${n}WhereUniqueInput & ${n}WhereInput;`);
1323
1393
  lines: [
1324
1394
  ` readonly by: readonly ${n}ScalarFieldEnum[];`,
1325
1395
  ` readonly where?: ${n}WhereInput;`,
1326
- ` readonly orderBy?: ${orMany({ type: `${n}OrderByInput` })};`,
1396
+ ` readonly orderBy?: ${orMany({ type: `${n}ScalarOrderByInput` })};`,
1327
1397
  ` readonly having?: ${n}WhereInput;`,
1328
1398
  ` readonly take?: number;`,
1329
1399
  ` readonly skip?: number;`,
@@ -1433,6 +1503,11 @@ ${relationCases}
1433
1503
  parts.push(`export type ${n}Delegate = {
1434
1504
  ${delegateMethods.join(`
1435
1505
  `)}
1506
+ };`);
1507
+ parts.push(`/** ${n}'s read surface on a \`$readOnly()\` client. Writes are not declared, and are refused at runtime with \`VIBE_READ_ONLY\`. */
1508
+ ` + `export type ${n}ReadOnlyDelegate = {
1509
+ ${[...readMethods, ...aggregateMethods, ...params.readOnlyExtensionLines ?? []].join(`
1510
+ `)}
1436
1511
  };`);
1437
1512
  return parts.join(`
1438
1513
 
@@ -1462,7 +1537,7 @@ function emitViewTypesForModel(params) {
1462
1537
  name: `${n}ViewNestedArgs`,
1463
1538
  lines: [
1464
1539
  ` readonly where?: ${n}WhereInput;`,
1465
- ` readonly orderBy?: ${orMany({ type: `${n}OrderByInput` })};`,
1540
+ ` readonly orderBy?: ${orMany({ type: `${n}ScalarOrderByInput` })};`,
1466
1541
  ` readonly take?: number;`,
1467
1542
  ` readonly skip?: number;`,
1468
1543
  ` readonly select?: ${n}ViewSelect;`,
@@ -1617,12 +1692,102 @@ ${lines.join(`
1617
1692
  };`
1618
1693
  ];
1619
1694
  }
1620
- function instanceSurfaceLines(params) {
1621
- const { view, selfType } = params;
1622
- const delegateProps = view.models.map((model) => ` readonly ${propKey({ name: model.clientName })}: ${model.name}Delegate;`);
1695
+ function emitLockSection(params) {
1696
+ const capabilities = capabilitiesOf(params);
1697
+ const example = capabilities.rowLockWaitPolicies.includes("skipLocked") ? [
1698
+ ` * @example Claim one queued job without waiting for another worker's row`,
1699
+ ` * await db.$transaction(async (tx) => {`,
1700
+ ` * const [job] = await tx.job.findMany({`,
1701
+ ` * where: { status: "queued" },`,
1702
+ ` * take: 1,`,
1703
+ ` * lock: { strength: "update", wait: "skipLocked" },`,
1704
+ ` * });`,
1705
+ ` * if (job !== undefined) await tx.job.update({ where: { id: job.id }, data: { status: "running" } });`,
1706
+ ` * });`
1707
+ ] : [
1708
+ ` * @example Read a row you are about to write`,
1709
+ ` * await db.$transaction(async (tx) => {`,
1710
+ ` * const account = await tx.account.findUnique({ where: { id }, lock: "update" });`,
1711
+ ` * await tx.account.update({ where: { id }, data: { balance: account.balance - amount } });`,
1712
+ ` * });`
1713
+ ];
1714
+ const body = capabilities.rowLockStrengths.length === 0 ? [
1715
+ `/**`,
1716
+ ` * A row-locking read. This database has NO locking-read clause, so the`,
1717
+ ` * historical \`"update"\` request emits no clause at all \u2014 a documented`,
1718
+ ` * no-op that costs nothing here, because its write transactions already`,
1719
+ ` * exclude each other. Every other strength and wait policy is refused`,
1720
+ ` * rather than silently downgraded, so they are absent from this type.`,
1721
+ ` *`,
1722
+ ` * Still REQUIRES a transaction: outside one the call is refused`,
1723
+ ` * (\`VIBE_VALIDATION\`), so code written here stays portable.`,
1724
+ ` *`,
1725
+ ...example,
1726
+ ` */`,
1727
+ `export type RowLockRequest = "update";`
1728
+ ] : [
1729
+ `/**`,
1730
+ ` * A row-locking read (\`SELECT \u2026 FOR UPDATE SKIP LOCKED\`).`,
1731
+ ` *`,
1732
+ ` * REQUIRES a transaction \u2014 call it on the client \`$transaction\` hands its`,
1733
+ ` * callback. Outside one the lock would be released at statement end, so`,
1734
+ ` * the call is refused with \`VIBE_VALIDATION\` instead.`,
1735
+ ` *`,
1736
+ ` * \`"update"\` is the shorthand for \`{ strength: "update", wait: "wait" }\`.`,
1737
+ ` * \`wait\` is ONE policy, never several flags: \`"wait"\` blocks until the`,
1738
+ ` * holder finishes (the default), \`"nowait"\` fails immediately with`,
1739
+ ` * \`VIBE_LOCK_NOT_AVAILABLE\`, and \`"skipLocked"\` leaves locked rows out of`,
1740
+ ` * the result. Refused with \`include\`/relation \`select\`, with \`distinct\`,`,
1741
+ ` * and on views.`,
1742
+ ` *`,
1743
+ ...example,
1744
+ ` */`,
1745
+ `export type RowLockRequest =`,
1746
+ ` | "update"`,
1747
+ ` | {`,
1748
+ ` readonly strength: ${literalUnion({ members: capabilities.rowLockStrengths })};`,
1749
+ ` readonly wait?: ${literalUnion({ members: capabilities.rowLockWaitPolicies })};`,
1750
+ ` };`
1751
+ ];
1752
+ return [banner({ title: "Row locks" }), body.join(`
1753
+ `)];
1754
+ }
1755
+ function lockArgumentLines() {
1756
+ return [
1757
+ ` /** Row-locking read \u2014 see {@link RowLockRequest}. Only inside \`$transaction\`. */`,
1758
+ ` readonly lock?: RowLockRequest;`
1759
+ ].join(`
1760
+ `);
1761
+ }
1762
+ function advisoryLockLines(params) {
1763
+ if (!capabilitiesOf(params).advisoryLocks)
1764
+ return "";
1765
+ return ` /**
1766
+ ` + ` * Lock an application-defined resource for the rest of the transaction \u2014
1767
+ ` + ` * including one that has no row yet. BLOCKS until the lock is held.
1768
+ ` + ` *
1769
+ ` + ` * Released by commit and by rollback; there is no unlock call, so nothing
1770
+ ` + ` * can leak onto a pooled connection. Only inside \`$transaction\` (outside
1771
+ ` + ` * one the lock would be gone before the next statement).
1772
+ ` + ` *
1773
+ ` + ` * @example Serialize an import of a supplier's price list
1774
+ ` + ` * await ${params.selfLabel}.$transaction(async (tx) => {
1775
+ ` + ` * await tx.$advisoryLock({ classId: 1, objectId: supplierId });
1776
+ ` + ` * await rebuildPrices(tx);
1777
+ ` + ` * });
1778
+ ` + ` */
1779
+ ` + ` readonly $advisoryLock: (options: AdvisoryLockOptions) => Promise<void>;
1780
+ ` + ` /** The same lock WITHOUT waiting: \`true\` when acquired, \`false\` when someone else holds it. The transaction stays usable either way. */
1781
+ ` + ` readonly $tryAdvisoryLock: (options: AdvisoryLockOptions) => Promise<boolean>;
1782
+ `;
1783
+ }
1784
+ function delegateSurfaceLines(params) {
1785
+ const { view, delegateSuffix } = params;
1786
+ const typeOf = (model) => `${model.name}${delegateSuffix}`;
1787
+ const delegateProps = view.models.map((model) => ` readonly ${propKey({ name: model.clientName })}: ${typeOf(model)};`);
1623
1788
  const modelsProps = view.models.flatMap((model) => {
1624
- const byClientName = ` readonly ${propKey({ name: model.clientName })}: ${model.name}Delegate;`;
1625
- return model.name === model.clientName ? [byClientName] : [byClientName, ` readonly ${propKey({ name: model.name })}: ${model.name}Delegate;`];
1789
+ const byClientName = ` readonly ${propKey({ name: model.clientName })}: ${typeOf(model)};`;
1790
+ return model.name === model.clientName ? [byClientName] : [byClientName, ` readonly ${propKey({ name: model.name })}: ${typeOf(model)};`];
1626
1791
  });
1627
1792
  return `${delegateProps.join(`
1628
1793
  `)}
@@ -1633,20 +1798,37 @@ ${modelsProps.join(`
1633
1798
  };
1634
1799
  ` + ` /** Subscribe to client events ("query" fires after every statement, with the same payload as onQuery \u2014 both fire). Returns an unsubscribe function. */
1635
1800
  ` + ` readonly $on: (event: "query", listener: (event: QueryEvent) => void) => () => void;
1636
- ` + ` /** ONE transaction. Callback form: everything on \`tx\` rides the same BEGIN/COMMIT; a throw rolls back. Array form (Prisma parity): UN-AWAITED delegate calls run sequentially in one transaction, results returned positionally. */
1801
+ `;
1802
+ }
1803
+ function transactionLines(params) {
1804
+ const { transactionType, reads } = params;
1805
+ const doc = reads ? ` /** ONE transaction of READS \u2014 a consistent snapshot. The callback's handle is read-only too, and the array form takes read operations only. */` : ` /** ONE transaction. Callback form: everything on \`tx\` rides the same BEGIN/COMMIT; a throw rolls back. Array form (Prisma parity): UN-AWAITED delegate calls run sequentially in one transaction, results returned positionally. */`;
1806
+ return `${doc}
1637
1807
  ` + ` readonly $transaction: {
1638
- ` + ` <T>(fn: (tx: ${selfType}) => Promise<T>, options?: TransactionOptions): Promise<T>;
1808
+ ` + ` <T>(fn: (tx: ${transactionType}) => Promise<T>, options?: TransactionOptions): Promise<T>;
1639
1809
  ` + ` <T extends readonly unknown[]>(operations: readonly [...T], options?: TransactionOptions): Promise<{ -readonly [K in keyof T]: Awaited<T[K]> }>;
1640
1810
  ` + ` };
1811
+ `;
1812
+ }
1813
+ var RAW_SURFACE_LINES = ` readonly $querySql: <T>(params: { readonly query: SqlFragment; readonly decoder: ResultDecoder<T> }) => Promise<T[]>;
1814
+ ` + ` readonly $executeSql: (params: { readonly query: SqlFragment }) => Promise<number>;
1641
1815
  ` + ` readonly $queryRaw: <T = Record<string, unknown>>(strings: TemplateStringsArray, ...values: unknown[]) => Promise<T[]>;
1642
1816
  ` + ` readonly $executeRaw: (strings: TemplateStringsArray, ...values: unknown[]) => Promise<number>;
1643
1817
  ` + ` readonly $queryRawUnsafe: <T = Record<string, unknown>>(text: string, ...values: unknown[]) => Promise<T[]>;
1644
1818
  ` + ` readonly $executeRawUnsafe: (text: string, ...values: unknown[]) => Promise<number>;
1645
- ` + ` readonly $connect: () => Promise<void>;
1819
+ `;
1820
+ var CONNECTION_SURFACE_LINES = ` readonly $connect: () => Promise<void>;
1646
1821
  ` + ` readonly $disconnect: () => Promise<void>;
1647
- ` + ` /** Field-masking views (v1 defineView parity): a read-only masked projection of models. */
1822
+ `;
1823
+ var DEFINE_VIEW_LINE = ` /** Field-masking views (v1 defineView parity): a read-only masked projection of models. */
1648
1824
  ` + ` readonly $defineView: <D extends ViewDefinitionInput>(options: { readonly name: string; readonly definition: D }) => ViewClient<D>;
1649
1825
  `;
1826
+ var READ_ONLY_LINE = ` /** A read-only handle over this one: reads pass through, and every write, raw-SQL and control method is refused with \`VIBE_READ_ONLY\`. Closed under composition \u2014 transactions, views, scoped and policy handles built from it stay read-only. */
1827
+ ` + ` readonly $readOnly: () => ReadOnlyClientInstance;
1828
+ `;
1829
+ function instanceSurfaceLines(params) {
1830
+ const { view, transactionType, inTransaction, selfLabel } = params;
1831
+ return delegateSurfaceLines({ view, delegateSuffix: "Delegate" }) + transactionLines({ transactionType, reads: false }) + RAW_SURFACE_LINES + (inTransaction ? advisoryLockLines({ view, selfLabel }) : "") + (inTransaction ? "" : CONNECTION_SURFACE_LINES) + DEFINE_VIEW_LINE;
1650
1832
  }
1651
1833
  function telemetryDeclarations() {
1652
1834
  return [
@@ -1792,7 +1974,21 @@ function emitClient(params) {
1792
1974
  `;
1793
1975
  if (nativeRls) {
1794
1976
  parts.push(`export type BoundVibeClientInstance = {
1795
- ` + instanceSurfaceLines({ view, selfType: "BoundVibeClientInstance" }) + clientExtensionLines + `};`);
1977
+ ` + instanceSurfaceLines({
1978
+ view,
1979
+ transactionType: "BoundVibeTransactionClientInstance",
1980
+ inTransaction: false,
1981
+ selfLabel: "db"
1982
+ }) + READ_ONLY_LINE + clientExtensionLines + `};`);
1983
+ parts.push(`/** The bound handle inside \`$transaction\`: same identity, plus the locks that require a transaction, minus the connection lifecycle the transaction owns. */
1984
+ ` + `export type BoundVibeTransactionClientInstance = {
1985
+ ` + instanceSurfaceLines({
1986
+ view,
1987
+ transactionType: "BoundVibeTransactionClientInstance",
1988
+ inTransaction: true,
1989
+ selfLabel: "tx"
1990
+ }) + READ_ONLY_LINE + clientExtensionLines + ` // Deliberately ABSENT: $connect / $disconnect (the open transaction owns the connection) and $withContext (identity is bound once, from the root).
1991
+ ` + `};`);
1796
1992
  parts.push(`export type VibeClientInstance = {
1797
1993
  ` + ` /** Bind the request's identity context \u2014 ALL of it, every declared slot \u2014 and get the client that can actually query. */
1798
1994
  ` + ` readonly $withContext: (context: RlsContext) => BoundVibeClientInstance;
@@ -1800,11 +1996,25 @@ function emitClient(params) {
1800
1996
  ` + ` readonly $on: (event: "query", listener: (event: QueryEvent) => void) => () => void;
1801
1997
  ` + ` readonly $connect: () => Promise<void>;
1802
1998
  ` + ` readonly $disconnect: () => Promise<void>;
1803
- ` + ` // Deliberately ABSENT: model delegates, $models, $transaction, $queryRaw / $executeRaw / $queryRawUnsafe / $executeRawUnsafe, $defineView and every extension mount \u2014 an unbound client has no identity, so it cannot read or write a row. Bind one with $withContext.
1999
+ ` + ` // Deliberately ABSENT: model delegates, $models, $transaction, $queryRaw / $executeRaw / $queryRawUnsafe / $executeRawUnsafe, $advisoryLock / $tryAdvisoryLock, $defineView, $readOnly and every extension mount \u2014 an unbound client has no identity, so it cannot read or write a row. Bind one with $withContext.
1804
2000
  ` + `};`);
1805
2001
  } else {
1806
2002
  parts.push(`export type VibeClientInstance = {
1807
- ` + instanceSurfaceLines({ view, selfType: "VibeClientInstance" }) + policyClientLines({ view }) + scopedClientLines({ view }) + clientExtensionLines + `};`);
2003
+ ` + instanceSurfaceLines({
2004
+ view,
2005
+ transactionType: "VibeTransactionClientInstance",
2006
+ inTransaction: false,
2007
+ selfLabel: "db"
2008
+ }) + policyClientLines({ view, selfType: "VibeClientInstance" }) + scopedClientLines({ view, scopedType: "ScopedClientInstance" }) + READ_ONLY_LINE + clientExtensionLines + `};`);
2009
+ parts.push(`/** The handle \`$transaction\` gives its callback. A helper that REQUIRES a transaction takes this type, and an ordinary client no longer satisfies it. */
2010
+ ` + `export type VibeTransactionClientInstance = {
2011
+ ` + instanceSurfaceLines({
2012
+ view,
2013
+ transactionType: "VibeTransactionClientInstance",
2014
+ inTransaction: true,
2015
+ selfLabel: "tx"
2016
+ }) + policyClientLines({ view, selfType: "VibeTransactionClientInstance" }) + scopedClientLines({ view, scopedType: "ScopedTransactionClientInstance" }) + READ_ONLY_LINE + clientExtensionLines + ` // Deliberately ABSENT: $connect / $disconnect \u2014 the open transaction owns the connection, and both are refused at runtime here too.
2017
+ ` + `};`);
1808
2018
  }
1809
2019
  parts.push(`/** Build a typed client over a database adapter. */
1810
2020
  export declare function VibeClient(options: VibeClientOptions): VibeClientInstance;`);
@@ -1843,17 +2053,7 @@ function emitScopedSection(params) {
1843
2053
  const byClientName = ` readonly ${propKey({ name: model.clientName })}: ${scopedDelegateType(model)};`;
1844
2054
  return model.name === model.clientName ? [byClientName] : [byClientName, ` readonly ${propKey({ name: model.name })}: ${scopedDelegateType(model)};`];
1845
2055
  });
1846
- return [
1847
- banner({ title: "Tenant scoping ($scoped)" }),
1848
- `/** What a scoped handle does when this model appears INSIDE another model's nested write: "verify" (default) rewrites reference verbs into tenant-verified lookups; "refuse" rejects them. */
1849
- export type ScopedNestedMode = "verify" | "refuse";`,
1850
- `/** Per-model tenancy classification \u2014 TOTAL: every model must be named ("none" = global, passes through untouched). */
1851
- export type ScopedModelsConfig = {
1852
- ${entries.join(`
1853
- `)}
1854
- };`,
1855
- `export type ScopedClientInstance = {
1856
- ` + `${delegateProps.join(`
2056
+ const scopedSurface = (params2) => `${delegateProps.join(`
1857
2057
  `)}
1858
2058
  ` + ` /** Delegates keyed by BOTH camelCase client name and model name \u2014 the same objects as the top-level properties (dynamic access). */
1859
2059
  ` + ` readonly $models: {
@@ -1864,14 +2064,27 @@ ${modelsProps.join(`
1864
2064
  ` + ` readonly $on: (event: "query", listener: (event: QueryEvent) => void) => () => void;
1865
2065
  ` + ` /** ONE transaction; the callback receives a handle that INHERITS the scope. The array form takes un-awaited scoped delegate calls. */
1866
2066
  ` + ` readonly $transaction: {
1867
- ` + ` <T>(fn: (tx: ScopedClientInstance) => Promise<T>, options?: TransactionOptions): Promise<T>;
2067
+ ` + ` <T>(fn: (tx: ${params2.transactionType}) => Promise<T>, options?: TransactionOptions): Promise<T>;
1868
2068
  ` + ` <T extends readonly unknown[]>(operations: readonly [...T], options?: TransactionOptions): Promise<{ -readonly [K in keyof T]: Awaited<T[K]> }>;
1869
2069
  ` + ` };
1870
- ` + ` readonly $connect: () => Promise<void>;
1871
- ` + ` readonly $disconnect: () => Promise<void>;
1872
- ` + ` /** Field-masking views compose over the scoped handle (columns \xD7 rows). */
2070
+ ` + (params2.inTransaction ? advisoryLockLines({ view, selfLabel: "tx" }) : CONNECTION_SURFACE_LINES) + ` /** Field-masking views compose over the scoped handle (columns \xD7 rows). */
1873
2071
  ` + ` readonly $defineView: <D extends ViewDefinitionInput>(options: { readonly name: string; readonly definition: D }) => ViewClient<D>;
1874
- ` + ` // Deliberately ABSENT: $queryRaw / $executeRaw / $queryRawUnsafe / $executeRawUnsafe (raw SQL cannot be tenant-verified \u2014 use the base client) and $scoped (scope once, from the base client).
2072
+ ` + READ_ONLY_LINE;
2073
+ return [
2074
+ banner({ title: "Tenant scoping ($scoped)" }),
2075
+ `/** What a scoped handle does when this model appears INSIDE another model's nested write: "verify" (default) rewrites reference verbs into tenant-verified lookups; "refuse" rejects them. */
2076
+ export type ScopedNestedMode = "verify" | "refuse";`,
2077
+ `/** Per-model tenancy classification \u2014 TOTAL: every model must be named ("none" = global, passes through untouched). */
2078
+ export type ScopedModelsConfig = {
2079
+ ${entries.join(`
2080
+ `)}
2081
+ };`,
2082
+ `export type ScopedClientInstance = {
2083
+ ` + scopedSurface({ transactionType: "ScopedTransactionClientInstance", inTransaction: false }) + ` // Deliberately ABSENT: $queryRaw / $executeRaw / $queryRawUnsafe / $executeRawUnsafe (raw SQL cannot be tenant-verified \u2014 use the base client) and $scoped (scope once, from the base client).
2084
+ ` + `};`,
2085
+ `/** The scoped handle inside \`$transaction\`: same scope, plus the locks that require a transaction. */
2086
+ ` + `export type ScopedTransactionClientInstance = {
2087
+ ` + scopedSurface({ transactionType: "ScopedTransactionClientInstance", inTransaction: true }) + ` // Deliberately ABSENT: the raw-SQL doors, $scoped, and $connect / $disconnect (the open transaction owns the connection).
1875
2088
  ` + `};`
1876
2089
  ];
1877
2090
  }
@@ -1881,7 +2094,7 @@ function scopedClientLines(params) {
1881
2094
  if (params.view.models.some((model) => model.policy !== undefined))
1882
2095
  return "";
1883
2096
  return ` /** Tenant scoping: a total per-model tenancy map + a scope value \u2192 a NEW handle whose every call is confined to that tenant. scope: null builds the handle but refuses calls on scoped models. */
1884
- ` + ` readonly $scoped: (config: { readonly scope: string | number | bigint | null; readonly models: ScopedModelsConfig }) => ScopedClientInstance;
2097
+ ` + ` readonly $scoped: (config: { readonly scope: string | number | bigint | null; readonly models: ScopedModelsConfig }) => ${params.scopedType};
1885
2098
  `;
1886
2099
  }
1887
2100
  function policyClientLines(params) {
@@ -1897,10 +2110,35 @@ function policyClientLines(params) {
1897
2110
  }
1898
2111
  const contextType = `{ ${[...contextKeys.entries()].map(([key, type]) => `readonly ${propKey({ name: key })}: ${type};`).join(" ")} }`;
1899
2112
  return ` /** Row scoping (@@policy): bind a per-request context; policied models fail closed without one. */
1900
- ` + ` readonly $withPolicy: (context: ${contextType}) => VibeClientInstance;
1901
- ` + ` /** Deliberate, greppable full-access escape from policy scoping. */
1902
- ` + ` readonly $bypassPolicy: () => VibeClientInstance;
2113
+ ` + ` readonly $withPolicy: (context: ${contextType}) => ${params.selfType};
2114
+ ` + ` /** Deliberate, greppable full-access escape from policy scoping. It escapes the ROW policy, never a read-only restriction. */
2115
+ ` + ` readonly $bypassPolicy: () => ${params.selfType};
2116
+ `;
2117
+ }
2118
+ function emitReadOnlySection(params) {
2119
+ const { view, baked } = params;
2120
+ const extensionLines = baked.readOnlyClientDts.length === 0 ? "" : `${baked.readOnlyClientDts.join(`
2121
+ `)}
1903
2122
  `;
2123
+ return [
2124
+ banner({ title: "Read-only clients ($readOnly)" }),
2125
+ `/**
2126
+ ` + ` * A read-only handle. Application-level, not a database privilege: it refuses
2127
+ ` + ` * the calls VibeORM would issue, and cannot stop another connection or a role
2128
+ ` + ` * with write rights \u2014 use a read-only database role or transaction when the
2129
+ ` + ` * database itself must enforce it.
2130
+ ` + ` *
2131
+ ` + ` * @example Hand a query resolver something that cannot write
2132
+ ` + ` * const reader = db.$readOnly();
2133
+ ` + ` * await reader.user.findMany(); // fine
2134
+ ` + ` * await reader.user.upsertMany({ \u2026 }); // VIBE_READ_ONLY, and a compile error
2135
+ ` + ` */
2136
+ ` + `export type ReadOnlyClientInstance = {
2137
+ ` + delegateSurfaceLines({ view, delegateSuffix: "ReadOnlyDelegate" }) + transactionLines({ transactionType: "ReadOnlyClientInstance", reads: true }) + DEFINE_VIEW_LINE + policyClientLines({ view, selfType: "ReadOnlyClientInstance" }) + scopedClientLines({ view, scopedType: "ReadOnlyClientInstance" }) + ` /** Idempotent \u2014 restricting an already-restricted handle returns the same handle. */
2138
+ ` + ` readonly $readOnly: () => ReadOnlyClientInstance;
2139
+ ` + extensionLines + ` // Deliberately ABSENT: every write method, $queryRaw / $executeRaw / $queryRawUnsafe / $executeRawUnsafe, $connect / $disconnect, $advisoryLock / $tryAdvisoryLock, $withContext, and every extension surface its author did not classify as a read.
2140
+ ` + `};`
2141
+ ];
1904
2142
  }
1905
2143
  function emitDts(params) {
1906
2144
  const { view } = params;
@@ -1920,8 +2158,20 @@ function emitDts(params) {
1920
2158
  ...baked.empty ? [] : ["VibeExtension"],
1921
2159
  ...hasComputed ? ["ComputedConfig"] : []
1922
2160
  ].sort();
2161
+ const runtimeTypeImports = [
2162
+ "SqlFragment",
2163
+ "ResultDecoder",
2164
+ "SqlColumn",
2165
+ "DatabaseAdapter",
2166
+ "DbNow",
2167
+ "QueryEvent",
2168
+ "Skip",
2169
+ "TransactionOptions",
2170
+ "WriteValidators",
2171
+ ...capabilitiesOf({ view }).advisoryLocks ? ["AdvisoryLockOptions"] : []
2172
+ ].sort();
1923
2173
  const importLines = [
1924
- `import type { DatabaseAdapter, DbNow, QueryEvent, Skip, TransactionOptions, WriteValidators } from "@vibeorm/runtime";`,
2174
+ `import type { ${runtimeTypeImports.join(", ")} } from "@vibeorm/runtime";`,
1925
2175
  `import type { ${schemaTypeImports.join(", ")} } from "@vibeorm/schema";`,
1926
2176
  ...jsonTypeImportLines({ view }),
1927
2177
  ...baked.dtsImports,
@@ -1936,6 +2186,14 @@ export type { VibeErrorCode } from "@vibeorm/schema";
1936
2186
  export type { Skip } from "@vibeorm/runtime";
1937
2187
  ` + `export { ${enumEntryExports({ view }).join(", ")} };`);
1938
2188
  parts.push(PREAMBLE);
2189
+ parts.push(`export { sql, decode, sqlProjection } from "@vibeorm/runtime";`);
2190
+ parts.push(`export type { SqlFragment, ResultDecoder, ExtendedDateTime, SqlColumn } from "@vibeorm/runtime";`);
2191
+ parts.push(`/** Mapped SQL references. No database handle or execution privileges are captured. */
2192
+ export declare const sqlModels: {
2193
+ ${view.models.map((model) => ` readonly ${propKey({ name: model.clientName })}: (options?: { readonly alias?: string }) => { readonly table: SqlFragment; readonly fields: { ${model.fields.map((field) => `readonly ${propKey({ name: field.name })}: SqlColumn<${field.scalar === "Json" ? "unknown" : payloadFieldType({ field })}>`).join("; ")} } };`).join(`
2194
+ `)}
2195
+ };`);
2196
+ parts.push(...emitLockSection({ view }));
1939
2197
  parts.push(...filterSection());
1940
2198
  for (const declared of view.enums)
1941
2199
  parts.push(emitEnum({ declared }));
@@ -1943,12 +2201,14 @@ export type { Skip } from "@vibeorm/runtime";
1943
2201
  const modelParts = [];
1944
2202
  for (const model of view.models) {
1945
2203
  const extensionLines = baked.delegateDts.get(model.name);
2204
+ const readOnlyExtensionLines = baked.readOnlyDelegateDts.get(model.name);
1946
2205
  const modelComputed = computedByModel.get(model.name);
1947
2206
  modelParts.push(emitModel({
1948
2207
  model,
1949
2208
  view,
1950
2209
  registry,
1951
2210
  ...extensionLines === undefined ? {} : { extensionLines },
2211
+ ...readOnlyExtensionLines === undefined ? {} : { readOnlyExtensionLines },
1952
2212
  ...modelComputed === undefined ? {} : { computed: modelComputed }
1953
2213
  }));
1954
2214
  }
@@ -1965,6 +2225,7 @@ export type { Skip } from "@vibeorm/runtime";
1965
2225
  }
1966
2226
  parts.push(...emitViewSection({ view }));
1967
2227
  parts.push(...emitScopedSection({ view }));
2228
+ parts.push(...emitReadOnlySection({ view, baked }));
1968
2229
  parts.push(...emitRlsSection({ view }));
1969
2230
  parts.push(emitClient({ view, baked, hasComputed }));
1970
2231
  return `${parts.join(`
@@ -2151,4 +2412,4 @@ export {
2151
2412
  bakeExtensions
2152
2413
  };
2153
2414
 
2154
- //# debugId=AC114FAAB4DBE66364756E2164756E21
2415
+ //# debugId=43DBEE51B9E7189D64756E2164756E21