@atscript/db 0.1.161 → 0.1.162
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/agg.cjs +1 -1
- package/dist/agg.d.cts +1 -1
- package/dist/agg.d.mts +1 -1
- package/dist/agg.mjs +1 -1
- package/dist/{aggregate-fns-B_sgl4mB.mjs → aggregate-fns-DsS9UQVB.mjs} +1 -1
- package/dist/{aggregate-fns-DFtKAv7Q.cjs → aggregate-fns-DupYzFD7.cjs} +1 -1
- package/dist/{buckets-8mG67Mtw.d.mts → buckets-DUTcrT4Z.d.mts} +475 -57
- package/dist/{buckets-B4T_alUe.d.cts → buckets-_iZV39Z3.d.cts} +475 -57
- package/dist/{column-diff-DpeUbhEI.cjs → column-diff-CZsMGQ_w.cjs} +846 -232
- package/dist/{column-diff-DR1glykD.mjs → column-diff-pdn53AIj.mjs} +829 -233
- package/dist/{validator-B3xlbrWf.d.cts → db-error-B_DRL1E9.d.cts} +18 -89
- package/dist/{validator-B3xlbrWf.d.mts → db-error-B_DRL1E9.d.mts} +18 -89
- package/dist/{db-error-JcfJqqpk.cjs → db-error-DmF1_xrz.cjs} +13 -0
- package/dist/{db-error-eSsQX7rt.mjs → db-error-hraC12B9.mjs} +8 -1
- package/dist/{db-validator-plugin-BWy60OvG.d.cts → db-validator-plugin-wRqMYNBI.d.cts} +6 -0
- package/dist/{db-validator-plugin-BWy60OvG.d.mts → db-validator-plugin-wRqMYNBI.d.mts} +6 -0
- package/dist/{fk-diff-5gX42hBt.d.mts → fk-diff-B5jW68T3.d.cts} +34 -10
- package/dist/{fk-diff-BWdQ7kRZ.d.cts → fk-diff-PhVBYohc.d.mts} +34 -10
- package/dist/index.cjs +12 -11
- package/dist/index.d.cts +24 -21
- package/dist/index.d.mts +24 -21
- package/dist/index.mjs +9 -12
- package/dist/{nested-writer-BOj_c_lI.mjs → nested-writer-ClUX0DB0.mjs} +16 -22
- package/dist/{nested-writer-Bl1SZS7x.cjs → nested-writer-yCy6wGuN.cjs} +16 -22
- package/dist/{object-CtPYTIvy.d.cts → object-Czia_qD4.d.cts} +7 -1
- package/dist/{object-CtPYTIvy.d.mts → object-Czia_qD4.d.mts} +7 -1
- package/dist/{object-Djg28csK.cjs → object-GjsuPNqo.cjs} +19 -0
- package/dist/{object-TkiJQ-Dp.mjs → object-PQ1J8jri.mjs} +14 -1
- package/dist/ops.cjs +6 -21
- package/dist/ops.mjs +6 -21
- package/dist/plugin.cjs +263 -323
- package/dist/plugin.mjs +163 -223
- package/dist/rel.cjs +2 -2
- package/dist/rel.d.cts +3 -3
- package/dist/rel.d.mts +3 -3
- package/dist/rel.mjs +2 -2
- package/dist/{relation-helpers-17_2f5hX.d.mts → relation-helpers-BAEkP4bH.d.cts} +1 -1
- package/dist/{relation-helpers-BLBrLQYi.d.cts → relation-helpers-BSYwxOSA.d.mts} +1 -1
- package/dist/{relation-loader-DeulLrAw.cjs → relation-loader-DujL_Mh9.cjs} +1 -1
- package/dist/{relation-loader-CNBMPfRr.mjs → relation-loader-Qv3LrQ42.mjs} +1 -1
- package/dist/{search-fields-CE8cmj9X.mjs → search-fields-0U0FAFGj.mjs} +47 -1
- package/dist/{search-fields-CT_UZ9fg.cjs → search-fields-Bi1n-pB1.cjs} +64 -0
- package/dist/search-fields-CHqqcC3W.d.cts +30 -0
- package/dist/search-fields-CHqqcC3W.d.mts +30 -0
- package/dist/shared.cjs +42 -26
- package/dist/shared.d.cts +98 -4
- package/dist/shared.d.mts +98 -4
- package/dist/shared.mjs +4 -2
- package/dist/sync.cjs +228 -65
- package/dist/sync.d.cts +41 -5
- package/dist/sync.d.mts +41 -5
- package/dist/sync.mjs +228 -66
- package/dist/validator-BcBtg8yW.d.cts +88 -0
- package/dist/validator-BcBtg8yW.d.mts +88 -0
- package/dist/{validator-B4YDLUkj.cjs → validator-DK9Ijpo7.cjs} +3 -2
- package/dist/{validator-B3fPJjS1.mjs → validator-DuDWsIdQ.mjs} +3 -2
- package/dist/validator.cjs +3 -3
- package/dist/validator.d.cts +4 -3
- package/dist/validator.d.mts +4 -3
- package/dist/validator.mjs +3 -3
- package/dist/{validation-utils-CtLNlBtB.cjs → view-validation-D50KWU7O.cjs} +452 -18
- package/dist/{validation-utils-BGjqc2jU.mjs → view-validation-QbqqXEAM.mjs} +364 -20
- package/package.json +6 -6
package/dist/plugin.mjs
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { i as NULL_WHEN_EMPTY_AGGREGATE_FNS,
|
|
2
|
-
import {
|
|
1
|
+
import { i as NULL_WHEN_EMPTY_AGGREGATE_FNS, t as AGG_ANNOTATIONS } from "./aggregate-fns-DsS9UQVB.mjs";
|
|
2
|
+
import { c as DERIVED_INCOMPATIBLE, l as JSON_LEAF_TYPES, o as numericTypeProblem, s as DB_ENTITY_ANNOTATIONS, t as ENCRYPTED_INCOMPATIBLE } from "./search-fields-0U0FAFGj.mjs";
|
|
3
3
|
import "./consts-C_-5_pFq.mjs";
|
|
4
|
-
import { C as
|
|
4
|
+
import { A as getParentTypeName, B as validateSiblingStringField, C as viewJoins, D as getDbTableOwner, E as getAnnotationAlias, F as refActionAnnotation, I as searchFieldVerdict, L as validateExclusiveWith, M as isNullableUnion, O as getNavTargetTypeName, P as primitiveBaseType, R as validateFieldBaseType, S as validateRefArgument, T as effectiveAnnotationNames, _ as hasAnyViewAnnotation, c as refuseCiphertext, d as viewFieldSeals, f as viewProps, g as forEachFieldRef, h as findViewCycle, i as encryptedTypeSearchProblem, j as integerMemberIndexProblem, k as getParentStruct, l as sealedSearchProblem, m as findFKFieldsPointingTo, o as isNavProp, p as earlierJoinTargets, s as jsonChainInfo, u as validateViewInterface, v as isAliasDecl, w as viewScopeTypes, x as validateQueryScope, y as isDbSourceDecl, z as validateIncompatible } from "./view-validation-QbqqXEAM.mjs";
|
|
5
|
+
import { AnnotationSpec, DEFAULT_FORMAT, annotationExample, getFieldsForType, isArray, isInterface, isPrimitive, isProp, isQueryComparison, isQueryLogical, isRef, isStructure } from "@atscript/core";
|
|
5
6
|
import path from "node:path";
|
|
6
7
|
import { fileURLToPath } from "node:url";
|
|
7
|
-
import { AnnotationSpec, DEFAULT_FORMAT, getFieldsForType, isArray, isInterface, isPrimitive, isProp, isQueryComparison, isQueryLogical, isRef, isStructure } from "@atscript/core";
|
|
8
8
|
//#region src/plugin/manifest.ts
|
|
9
9
|
/**
|
|
10
10
|
* Renders the model-manifest module: an inventory of every exported
|
|
@@ -333,7 +333,7 @@ const VIEW_SOURCE_ARGUMENT = {
|
|
|
333
333
|
* @since 0.1.141
|
|
334
334
|
*/
|
|
335
335
|
const dbAliasAnnotations = { alias: new AnnotationSpec({
|
|
336
|
-
description: "Declares a **join alias**: a type alias of a `@db.table` or `@db.view` that a view can join under its own name — to join the same table twice or to self-join the entry table. Only valid on `export type X = Target`, where `Target` is the argument. The alias is a scope name inside view definitions, not a table: it is never synced or registered on a `DbSpace`, and it cannot be the `@db.view.for` entry
|
|
336
|
+
description: annotationExample("Declares a **join alias**: a type alias of a `@db.table` or `@db.view` that a view can join under its own name — to join the same table twice or to self-join the entry table. Only valid on `export type X = Target`, where `Target` is the argument. The alias is a scope name inside view definitions, not a table: it is never synced or registered on a `DbSpace`, and it cannot be the `@db.view.for` entry.", "@db.alias Employee", "export type Manager = Employee", "", "@db.view.for Employee", "@db.view.joins Manager, `Manager.id = Employee.managerId`, 'left'", "export interface Staff {", " id: Employee.id", " managerName?: Manager.name", "}"),
|
|
337
337
|
nodeType: ["type"],
|
|
338
338
|
passedWhenReferred: false,
|
|
339
339
|
argument: {
|
|
@@ -370,7 +370,7 @@ const dbAliasAnnotations = { alias: new AnnotationSpec({
|
|
|
370
370
|
const CURRENCY_CODE_PATTERN = /^[A-Z0-9]{2,10}$/;
|
|
371
371
|
const dbAmountAnnotations = { amount: { currency: {
|
|
372
372
|
$self: new AnnotationSpec({
|
|
373
|
-
description: "Hard-coded **currency code** for this amount field. Use when a table stores amounts in a single, fixed currency.\n\nFor per-row currency, use `@db.amount.currency.ref 'fieldName'` instead.\n\nOnly valid on `decimal` fields — money in floating-point is wrong
|
|
373
|
+
description: annotationExample("Hard-coded **currency code** for this amount field. Use when a table stores amounts in a single, fixed currency.\n\nFor per-row currency, use `@db.amount.currency.ref 'fieldName'` instead.\n\nOnly valid on `decimal` fields — money in floating-point is wrong.", "@db.amount.currency 'EUR'", "amount: decimal"),
|
|
374
374
|
nodeType: ["prop"],
|
|
375
375
|
multiple: false,
|
|
376
376
|
argument: {
|
|
@@ -384,7 +384,7 @@ const dbAmountAnnotations = { amount: { currency: {
|
|
|
384
384
|
errors.push(...validateExclusiveWith(token, "@db.amount.currency", [{
|
|
385
385
|
key: "db.amount.currency.ref",
|
|
386
386
|
displayName: "@db.amount.currency.ref"
|
|
387
|
-
}]));
|
|
387
|
+
}], doc));
|
|
388
388
|
const code = args[0]?.text;
|
|
389
389
|
if (code && !CURRENCY_CODE_PATTERN.test(code)) errors.push({
|
|
390
390
|
message: `@db.amount.currency '${code}': invalid currency code (expected 2–10 uppercase letters or digits)`,
|
|
@@ -395,7 +395,7 @@ const dbAmountAnnotations = { amount: { currency: {
|
|
|
395
395
|
}
|
|
396
396
|
}),
|
|
397
397
|
ref: new AnnotationSpec({
|
|
398
|
-
description: "Binds this amount to a **sibling field** that holds its currency code. Use when each row may have a different currency.\n\nThe referenced field should be of type `db.currencyCode` (or at least a `string`).\n\nOnly valid on `decimal` fields
|
|
398
|
+
description: annotationExample("Binds this amount to a **sibling field** that holds its currency code. Use when each row may have a different currency.\n\nThe referenced field should be of type `db.currencyCode` (or at least a `string`).\n\nOnly valid on `decimal` fields.", "currency: db.currencyCode", "@db.amount.currency.ref 'currency'", "amount: decimal"),
|
|
399
399
|
nodeType: ["prop"],
|
|
400
400
|
passedWhenReferred: false,
|
|
401
401
|
multiple: false,
|
|
@@ -413,114 +413,14 @@ const dbAmountAnnotations = { amount: { currency: {
|
|
|
413
413
|
}, {
|
|
414
414
|
key: "db.unit.ref",
|
|
415
415
|
displayName: "@db.unit.ref"
|
|
416
|
-
}]));
|
|
416
|
+
}], doc));
|
|
417
417
|
errors.push(...validateSiblingStringField(token, args, doc, "@db.amount.currency.ref"));
|
|
418
418
|
return errors;
|
|
419
419
|
}
|
|
420
420
|
})
|
|
421
421
|
} } };
|
|
422
422
|
//#endregion
|
|
423
|
-
//#region src/shared/view-validation.ts
|
|
424
|
-
/** `@db.agg.*` annotations that are never NULL (the counts: 0 over no value). */
|
|
425
|
-
const NULL_SAFE_AGG_ANNOTATIONS = SUPPORTED_AGGREGATE_FNS.filter((fn) => !NULL_WHEN_EMPTY_AGGREGATE_FNS.has(fn)).map((fn) => `db.agg.${fn}`);
|
|
426
|
-
/**
|
|
427
|
-
* Walks a chain ref step by step (`Order.payload`, `Order.payload.customer`, …)
|
|
428
|
-
* and reports the JSON root, arrays, encryption, optionality and the leaf type.
|
|
429
|
-
* A chain shorter than 2 is not a field path (`resolved: false`).
|
|
430
|
-
* @since 0.1.141
|
|
431
|
-
*/
|
|
432
|
-
function jsonChainInfo(ref, doc) {
|
|
433
|
-
const typeName = ref.id ?? "";
|
|
434
|
-
const chain = ref.chain.map((t) => t.text);
|
|
435
|
-
const info = {
|
|
436
|
-
typeName,
|
|
437
|
-
chain,
|
|
438
|
-
resolved: false,
|
|
439
|
-
viaArray: false,
|
|
440
|
-
viaEncrypted: false,
|
|
441
|
-
optional: false
|
|
442
|
-
};
|
|
443
|
-
if (!typeName || chain.length < 2) return info;
|
|
444
|
-
for (let i = 1; i <= chain.length; i++) {
|
|
445
|
-
const step = doc.unwindType(typeName, chain.slice(0, i));
|
|
446
|
-
if (!step) return info;
|
|
447
|
-
const node = step.node;
|
|
448
|
-
const prop = node && isProp(node) ? node : void 0;
|
|
449
|
-
if (prop?.has("optional")) info.optional = true;
|
|
450
|
-
if (prop && prop.countAnnotations("db.encrypted") > 0) info.viaEncrypted = true;
|
|
451
|
-
const isJsonProp = prop !== void 0 && prop.countAnnotations("db.json") > 0;
|
|
452
|
-
const isArr = isArray(step.def);
|
|
453
|
-
if (isArr) info.viaArray = true;
|
|
454
|
-
if (info.jsonRoot === void 0 && (isJsonProp || isArr)) info.jsonRoot = i;
|
|
455
|
-
if (i === chain.length) info.leafType = primitiveBaseType(step.def);
|
|
456
|
-
}
|
|
457
|
-
info.resolved = true;
|
|
458
|
-
return info;
|
|
459
|
-
}
|
|
460
|
-
/** Props of a view interface (its own structure; views don't use `extends`). */
|
|
461
|
-
function viewProps(owner) {
|
|
462
|
-
if (isInterface(owner)) return owner.props;
|
|
463
|
-
const def = owner.getDefinition();
|
|
464
|
-
return def && isStructure(def) ? def.props : void 0;
|
|
465
|
-
}
|
|
466
|
-
/**
|
|
467
|
-
* VW8: a chain ref that passes through a `@db.json` or array node reads a
|
|
468
|
-
* JSON leaf, which views can only extract as a string / number / boolean.
|
|
469
|
-
* Intermediate nodes that `unwindType` can't reach are skipped (sync-time
|
|
470
|
-
* resolution reports them).
|
|
471
|
-
*/
|
|
472
|
-
function validateJsonChain(fieldName, ref, doc, range) {
|
|
473
|
-
const info = jsonChainInfo(ref, doc);
|
|
474
|
-
if (!info.resolved || info.jsonRoot === void 0 || info.jsonRoot >= info.chain.length) return [];
|
|
475
|
-
if (info.leafType !== void 0 && JSON_LEAF_TYPES.has(info.leafType)) return [];
|
|
476
|
-
return [{
|
|
477
|
-
message: `Field "${fieldName}" reads "${info.typeName}.${info.chain.join(".")}" inside a JSON-stored field — it must end at a string, number or boolean leaf`,
|
|
478
|
-
severity: 1,
|
|
479
|
-
range
|
|
480
|
-
}];
|
|
481
|
-
}
|
|
482
|
-
/**
|
|
483
|
-
* Whole-view checks, run once per `@db.view.for` interface:
|
|
484
|
-
*
|
|
485
|
-
* - VW7 — a field reading from a left-joined table must be optional (the
|
|
486
|
-
* join yields NULL for unmatched rows); `@db.agg.count` / `countDistinct`
|
|
487
|
-
* fields are exempt (they count 0, never NULL).
|
|
488
|
-
* - VW8 — a chain ref through a `@db.json` or array node must end at a
|
|
489
|
-
* primitive string / number / boolean leaf.
|
|
490
|
-
* @since 0.1.136
|
|
491
|
-
*/
|
|
492
|
-
function validateViewInterface(owner, doc) {
|
|
493
|
-
const errors = [];
|
|
494
|
-
const props = viewProps(owner);
|
|
495
|
-
if (!props) return errors;
|
|
496
|
-
const leftJoined = /* @__PURE__ */ new Set();
|
|
497
|
-
for (const join of viewJoins(owner)) {
|
|
498
|
-
const target = join.args[0]?.text;
|
|
499
|
-
if (target && join.args[2]?.text === "left") leftJoined.add(target);
|
|
500
|
-
}
|
|
501
|
-
for (const [fieldName, prop] of props) {
|
|
502
|
-
const def = prop.getDefinition();
|
|
503
|
-
if (!def || !isRef(def)) continue;
|
|
504
|
-
const ref = def;
|
|
505
|
-
const range = (prop.token("identifier") ?? ref.token("identifier"))?.range;
|
|
506
|
-
if (!range) continue;
|
|
507
|
-
if (ref.id && leftJoined.has(ref.id) && !prop.has("optional") && !NULL_SAFE_AGG_ANNOTATIONS.some((name) => prop.countAnnotations(name) > 0)) errors.push({
|
|
508
|
-
message: `Field "${fieldName}" reads from left-joined "${ref.id}" and must be optional (${fieldName}?: …)`,
|
|
509
|
-
severity: 1,
|
|
510
|
-
range
|
|
511
|
-
});
|
|
512
|
-
errors.push(...validateJsonChain(fieldName, ref, doc, range));
|
|
513
|
-
}
|
|
514
|
-
return errors;
|
|
515
|
-
}
|
|
516
|
-
//#endregion
|
|
517
423
|
//#region src/plugin/annotations/column.ts
|
|
518
|
-
/** Nav-field annotations: a navigation field has no column on this table. */
|
|
519
|
-
const NAV_ANNOTATIONS = [
|
|
520
|
-
"db.rel.to",
|
|
521
|
-
"db.rel.from",
|
|
522
|
-
"db.rel.via"
|
|
523
|
-
];
|
|
524
424
|
/**
|
|
525
425
|
* Compile-time placement rules of `@db.column.version.exempt` (E1–E5, W1, W2).
|
|
526
426
|
* E6 (derived) lives in `DERIVED_INCOMPATIBLE`. The runtime mirror is
|
|
@@ -540,7 +440,7 @@ function validateVersionExempt(token) {
|
|
|
540
440
|
const tag = "@db.column.version.exempt";
|
|
541
441
|
if (field.countAnnotations("db.column.version") > 0) fail(`${tag} cannot mark the version column itself`);
|
|
542
442
|
if (field.countAnnotations("meta.id") > 0) fail(`${tag} cannot mark a primary key — it identifies the row and is never patched`);
|
|
543
|
-
if (
|
|
443
|
+
if (isNavProp(field)) fail(`${tag} cannot mark a navigation field — related rows follow their own table's versioning`);
|
|
544
444
|
if (field.countAnnotations("db.ignore") > 0) fail(`${tag} has no effect on an ignored field`, 2);
|
|
545
445
|
let node = field;
|
|
546
446
|
let tableOwner;
|
|
@@ -577,9 +477,51 @@ function validateVersionExempt(token) {
|
|
|
577
477
|
}
|
|
578
478
|
return errors;
|
|
579
479
|
}
|
|
480
|
+
/** Annotations a `@db.onUpdate.inc` field cannot carry, with the reason. */
|
|
481
|
+
const ON_UPDATE_INC_INCOMPATIBLE = [
|
|
482
|
+
["meta.id", "the primary key identifies the row and is never updated"],
|
|
483
|
+
["db.column.version", "the version column is already bumped on every update"],
|
|
484
|
+
["db.onUpdate.now", "a field is either a time stamp or a counter"],
|
|
485
|
+
["db.default", "the counter starts at 0"],
|
|
486
|
+
["db.default.increment", "the counter starts at 0"],
|
|
487
|
+
["db.default.uuid", "the counter starts at 0"],
|
|
488
|
+
["db.default.now", "the counter starts at 0"],
|
|
489
|
+
["db.json", "a JSON column is written as one value"]
|
|
490
|
+
];
|
|
491
|
+
/**
|
|
492
|
+
* Compile-time rules of `@db.onUpdate.inc` (since 0.1.162). The runtime
|
|
493
|
+
* mirror (top level, encryption) is in `TableMetadata`.
|
|
494
|
+
*/
|
|
495
|
+
function validateOnUpdateInc(token, doc) {
|
|
496
|
+
const tag = "@db.onUpdate.inc";
|
|
497
|
+
const errors = validateFieldBaseType(token, doc, tag, "number");
|
|
498
|
+
const field = token.parentNode;
|
|
499
|
+
const fail = (message) => {
|
|
500
|
+
errors.push({
|
|
501
|
+
message,
|
|
502
|
+
severity: 1,
|
|
503
|
+
range: token.range
|
|
504
|
+
});
|
|
505
|
+
};
|
|
506
|
+
if (errors.length === 0 && !isIntegerField(field, doc)) fail(`${tag} requires an integer field — use number.int (or @expect.int)`);
|
|
507
|
+
if (field.has("optional")) fail(`${tag} requires a non-optional field — the counter is always populated (default 0)`);
|
|
508
|
+
const struct = getParentStruct(token);
|
|
509
|
+
if (!struct?.ownerNode || !isInterface(struct.ownerNode)) fail(`${tag} must sit on a top-level field of the table, not inside a nested object`);
|
|
510
|
+
errors.push(...validateIncompatible(token, tag, ON_UPDATE_INC_INCOMPATIBLE, doc));
|
|
511
|
+
return errors;
|
|
512
|
+
}
|
|
513
|
+
/** Whether a numeric field is an integer: `number.int`, an `integer` base or `@expect.int`. */
|
|
514
|
+
function isIntegerField(field, doc) {
|
|
515
|
+
const def = field.getDefinition();
|
|
516
|
+
if (!def || !isRef(def)) return true;
|
|
517
|
+
const leaf = doc.unwindType(def.id, def.chain)?.def;
|
|
518
|
+
if (primitiveBaseType(leaf) !== "number") return true;
|
|
519
|
+
if (leaf.tags?.has("int")) return true;
|
|
520
|
+
return effectiveAnnotationNames(field, doc).has("expect.int");
|
|
521
|
+
}
|
|
580
522
|
const dbColumnAnnotations = {
|
|
581
523
|
patch: { strategy: new AnnotationSpec({
|
|
582
|
-
description: "Defines the **patching strategy** for updating nested objects.\n\n- **\"replace\"** → The field or object will be **fully replaced**.\n- **\"merge\"** → The field or object will be **merged recursively** (applies only to objects, not arrays)
|
|
524
|
+
description: annotationExample("Defines the **patching strategy** for updating nested objects.\n\n- **\"replace\"** → The field or object will be **fully replaced**.\n- **\"merge\"** → The field or object will be **merged recursively** (applies only to objects, not arrays).", "@db.patch.strategy \"merge\"", "settings: {", " notifications: boolean", " preferences: {", " theme: string", " }", "}"),
|
|
583
525
|
nodeType: ["prop"],
|
|
584
526
|
passedWhenReferred: false,
|
|
585
527
|
multiple: false,
|
|
@@ -609,7 +551,7 @@ const dbColumnAnnotations = {
|
|
|
609
551
|
}) },
|
|
610
552
|
column: {
|
|
611
553
|
$self: new AnnotationSpec({
|
|
612
|
-
description: "Overrides the physical column name in the database. For nested (flattened) fields, the parent prefix is still prepended automatically. Document storage (MongoDB) renames top-level fields only — a nested field keeps its name there
|
|
554
|
+
description: annotationExample("Overrides the physical column name in the database. For nested (flattened) fields, the parent prefix is still prepended automatically. Document storage (MongoDB) renames top-level fields only — a nested field keeps its name there.", "@db.column \"first_name\"", "firstName: string", "// → physical column: first_name", "", "// Nested:", "address: {", " @db.column \"zip_code\"", " zip: string", "}", "// → physical column: address__zip_code"),
|
|
613
555
|
nodeType: ["prop"],
|
|
614
556
|
passedWhenReferred: false,
|
|
615
557
|
argument: {
|
|
@@ -619,7 +561,7 @@ const dbColumnAnnotations = {
|
|
|
619
561
|
}
|
|
620
562
|
}),
|
|
621
563
|
renamed: new AnnotationSpec({
|
|
622
|
-
description: "Specifies the previous local field name for column rename migration. The sync engine generates ALTER TABLE RENAME COLUMN instead of drop+add (on document storage, top-level fields only)
|
|
564
|
+
description: annotationExample("Specifies the previous local field name for column rename migration. The sync engine generates ALTER TABLE RENAME COLUMN instead of drop+add (on document storage, top-level fields only).", "@db.column.renamed \"zip\"", "postalCode: string", "// Renames address__zip → address__postalCode"),
|
|
623
565
|
nodeType: ["prop"],
|
|
624
566
|
passedWhenReferred: false,
|
|
625
567
|
argument: {
|
|
@@ -629,7 +571,7 @@ const dbColumnAnnotations = {
|
|
|
629
571
|
}
|
|
630
572
|
}),
|
|
631
573
|
collate: new AnnotationSpec({
|
|
632
|
-
description: "Portable collation for string comparison and sorting. Adapters map the generic value to their native collation.\n\n- **\"binary\"** — exact byte comparison (case-sensitive)\n- **\"nocase\"** — case-insensitive comparison\n- **\"unicode\"** — full Unicode-aware sorting\n\nFor adapter-specific collations, use `@db.<engine>.collate` instead
|
|
574
|
+
description: annotationExample("Portable collation for string comparison and sorting. Adapters map the generic value to their native collation.\n\n- **\"binary\"** — exact byte comparison (case-sensitive)\n- **\"nocase\"** — case-insensitive comparison\n- **\"unicode\"** — full Unicode-aware sorting\n\nFor adapter-specific collations, use `@db.<engine>.collate` instead.", "@db.column.collate \"nocase\"", "username: string"),
|
|
633
575
|
nodeType: ["prop"],
|
|
634
576
|
passedWhenReferred: false,
|
|
635
577
|
argument: {
|
|
@@ -647,7 +589,7 @@ const dbColumnAnnotations = {
|
|
|
647
589
|
}
|
|
648
590
|
}),
|
|
649
591
|
precision: new AnnotationSpec({
|
|
650
|
-
description: "Sets decimal precision and scale for database storage. Adapters map this to their native decimal type (e.g., `DECIMAL(10,2)` in SQL, ignored in MongoDB).\n\nFor `decimal` fields the runtime value is a string; for `number` fields this is a DB storage hint only
|
|
592
|
+
description: annotationExample("Sets decimal precision and scale for database storage. Adapters map this to their native decimal type (e.g., `DECIMAL(10,2)` in SQL, ignored in MongoDB).\n\nFor `decimal` fields the runtime value is a string; for `number` fields this is a DB storage hint only.", "@db.column.precision 10, 2", "price: decimal"),
|
|
651
593
|
nodeType: ["prop"],
|
|
652
594
|
passedWhenReferred: false,
|
|
653
595
|
argument: [{
|
|
@@ -664,7 +606,7 @@ const dbColumnAnnotations = {
|
|
|
664
606
|
}
|
|
665
607
|
}),
|
|
666
608
|
derived: new AnnotationSpec({
|
|
667
|
-
description: "Declares a **derived column**: a read-only column computed from one `string`, `number` or `boolean` leaf inside a `@db.json` field of the **same table**, so the leaf can be filtered, sorted, grouped and indexed like a real column. The field's type is a chain reference into that field (`customerId: Order.payload.customer.id`); the path may not cross an array or a `@db.encrypted` field. Declare the field optional when the path can be absent (a missing leaf reads as `null`). A value written to it is dropped; `$inc` / `$dec` / `$mul` on it are rejected.\n\nStorage per adapter, schema sync and the compatible annotations: https://atscript.dev/db/api/storage#derived-columns
|
|
609
|
+
description: annotationExample("Declares a **derived column**: a read-only column computed from one `string`, `number` or `boolean` leaf inside a `@db.json` field of the **same table**, so the leaf can be filtered, sorted, grouped and indexed like a real column. The field's type is a chain reference into that field (`customerId: Order.payload.customer.id`); the path may not cross an array or a `@db.encrypted` field. Declare the field optional when the path can be absent (a missing leaf reads as `null`). A value written to it is dropped; `$inc` / `$dec` / `$mul` on it are rejected.\n\nStorage per adapter, schema sync and the compatible annotations: https://atscript.dev/db/api/storage#derived-columns", "@db.table 'orders'", "export interface Order {", " @meta.id", " id: number", "", " @db.json", " payload: {", " customer: { id: string, vip: boolean }", " total: number", " }", "", " @db.column.derived", " @db.index.plain", " customerId: Order.payload.customer.id", "", " @db.column.derived", " vip?: Order.payload.customer.vip", "}"),
|
|
668
610
|
nodeType: ["prop"],
|
|
669
611
|
passedWhenReferred: false,
|
|
670
612
|
multiple: false,
|
|
@@ -739,11 +681,13 @@ const dbColumnAnnotations = {
|
|
|
739
681
|
filterable: columnCapability("filterable", "filtering"),
|
|
740
682
|
sortable: columnCapability("sortable", "sorting"),
|
|
741
683
|
searchable: new AnnotationSpec({
|
|
742
|
-
description: "Includes this column in the generic `$search` fallback: when the adapter reports no native search capability (no FTS / Atlas index configured), the readable controller matches the `$search` term as a case-insensitive substring across all `@db.column.searchable` fields (`$or`). Where adapter-native search IS available it wins and this annotation is not consulted. The term is escaped literally — no user-supplied regex. String and integer columns only: an integer column (`number.int` and its sizes, `@expect.int`, `@db.default.increment`) matches when the term is a substring of the number's decimal text (`2946` finds `29461277`). Floats, decimals and timestamps are refused
|
|
684
|
+
description: annotationExample("Includes this column in the generic `$search` fallback: when the adapter reports no native search capability (no FTS / Atlas index configured), the readable controller matches the `$search` term as a case-insensitive substring across all `@db.column.searchable` fields (`$or`). Where adapter-native search IS available it wins and this annotation is not consulted. The term is escaped literally — no user-supplied regex. String and integer columns only: an integer column (`number.int` and its sizes, `@expect.int`, `@db.default.increment`) matches when the term is a substring of the number's decimal text (`2946` finds `29461277`). Floats, decimals and timestamps are refused.", "@db.table \"jobs\"", "export interface Job {", " @db.column.searchable", " jobName: string", " @db.column.searchable", " description: string", " @db.column.searchable", " refNo: number.int", "}"),
|
|
743
685
|
nodeType: ["prop"],
|
|
744
686
|
passedWhenReferred: false,
|
|
745
687
|
multiple: false,
|
|
746
688
|
validate(token, _args, doc) {
|
|
689
|
+
const sealed = sealedSearchProblem(token, doc, "@db.column.searchable");
|
|
690
|
+
if (sealed) return [sealed];
|
|
747
691
|
const verdict = searchFieldVerdict(token.parentNode, doc);
|
|
748
692
|
if (!("problem" in verdict)) return [];
|
|
749
693
|
return [{
|
|
@@ -755,7 +699,7 @@ const dbColumnAnnotations = {
|
|
|
755
699
|
}),
|
|
756
700
|
version: {
|
|
757
701
|
$self: new AnnotationSpec({
|
|
758
|
-
description: "Marks a numeric column as the row's version for optimistic concurrency control (OCC). The adapter auto-increments this column on every UPDATE, and callers may pass `$cas: { <col>: N }` in a write payload to make the update conditional on the current version. Direct writes to the version column (as plain SET, `$inc`, or `$mul`) are rejected. Fields marked `@db.column.version.exempt` do not bump it.\n\n**Constraints:**\n- At most one version column per table.\n- Must resolve to an integer type (`int`, `int32`, `int64`, etc.).\n- Default value on insert is `0
|
|
702
|
+
description: annotationExample("Marks a numeric column as the row's version for optimistic concurrency control (OCC). The adapter auto-increments this column on every UPDATE, and callers may pass `$cas: { <col>: N }` in a write payload to make the update conditional on the current version. Direct writes to the version column (as plain SET, `$inc`, or `$mul`) are rejected. Fields marked `@db.column.version.exempt` do not bump it.\n\n**Constraints:**\n- At most one version column per table.\n- Must resolve to an integer type (`int`, `int32`, `int64`, etc.).\n- Default value on insert is `0`.", "@db.column.version", "version: int"),
|
|
759
703
|
nodeType: ["prop"],
|
|
760
704
|
passedWhenReferred: false,
|
|
761
705
|
validate(token, _args, doc) {
|
|
@@ -779,7 +723,7 @@ const dbColumnAnnotations = {
|
|
|
779
723
|
}
|
|
780
724
|
}),
|
|
781
725
|
exempt: new AnnotationSpec({
|
|
782
|
-
description: "Marks a field as **version-exempt**: a patch that writes ONLY exempt fields (`updateOne` / `bulkUpdate` without `$cas`, `updateMany`) leaves `@db.column.version` unchanged and adds no version check. A patch touching any other field bumps as usual; `$cas`, replace and `touchMany` always bump. Use it for derived/reporting columns refreshed in the background (scores, counters, caches) so they do not invalidate versions held by editors. On an object field it covers every nested field
|
|
726
|
+
description: annotationExample("Marks a field as **version-exempt**: a patch that writes ONLY exempt fields (`updateOne` / `bulkUpdate` without `$cas`, `updateMany`) leaves `@db.column.version` unchanged and adds no version check. A patch touching any other field bumps as usual; `$cas`, replace and `touchMany` always bump. Use it for derived/reporting columns refreshed in the background (scores, counters, caches) so they do not invalidate versions held by editors. On an object field it covers every nested field.", "@db.column.version", "version: number.int", "@db.column.version.exempt", "score: number"),
|
|
783
727
|
nodeType: ["prop"],
|
|
784
728
|
passedWhenReferred: false,
|
|
785
729
|
multiple: false,
|
|
@@ -791,7 +735,7 @@ const dbColumnAnnotations = {
|
|
|
791
735
|
},
|
|
792
736
|
default: {
|
|
793
737
|
$self: new AnnotationSpec({
|
|
794
|
-
description: "Sets a static DB-level default value (used in DDL DEFAULT clause). For string fields the value is used as-is; for other types it is parsed as JSON
|
|
738
|
+
description: annotationExample("Sets a static DB-level default value (used in DDL DEFAULT clause). For string fields the value is used as-is; for other types it is parsed as JSON.", "@db.default \"active\"", "status: string"),
|
|
795
739
|
nodeType: ["prop"],
|
|
796
740
|
passedWhenReferred: false,
|
|
797
741
|
argument: {
|
|
@@ -801,7 +745,7 @@ const dbColumnAnnotations = {
|
|
|
801
745
|
}
|
|
802
746
|
}),
|
|
803
747
|
increment: new AnnotationSpec({
|
|
804
|
-
description: "Auto-incrementing integer default. Each adapter maps this to its native mechanism (e.g., `AUTO_INCREMENT` in MySQL, `INTEGER PRIMARY KEY` in SQLite, counter collection in MongoDB)
|
|
748
|
+
description: annotationExample("Auto-incrementing integer default. Each adapter maps this to its native mechanism (e.g., `AUTO_INCREMENT` in MySQL, `INTEGER PRIMARY KEY` in SQLite, counter collection in MongoDB).", "@db.default.increment", "id: number.int", "", "// With optional start value:", "@db.default.increment 1000", "id: number.int"),
|
|
805
749
|
nodeType: ["prop"],
|
|
806
750
|
passedWhenReferred: false,
|
|
807
751
|
argument: {
|
|
@@ -821,7 +765,7 @@ const dbColumnAnnotations = {
|
|
|
821
765
|
}
|
|
822
766
|
}),
|
|
823
767
|
uuid: new AnnotationSpec({
|
|
824
|
-
description: "UUID generation default. Each adapter maps this to its native mechanism (e.g., `DEFAULT (UUID())` in MySQL, `gen_random_uuid()` in PostgreSQL, app-level in SQLite)
|
|
768
|
+
description: annotationExample("UUID generation default. Each adapter maps this to its native mechanism (e.g., `DEFAULT (UUID())` in MySQL, `gen_random_uuid()` in PostgreSQL, app-level in SQLite).", "@db.default.uuid", "id: string.uuid"),
|
|
825
769
|
nodeType: ["prop"],
|
|
826
770
|
passedWhenReferred: false,
|
|
827
771
|
validate(token, args, doc) {
|
|
@@ -829,7 +773,7 @@ const dbColumnAnnotations = {
|
|
|
829
773
|
}
|
|
830
774
|
}),
|
|
831
775
|
now: new AnnotationSpec({
|
|
832
|
-
description: "Current timestamp default. Each adapter maps this to its native mechanism (e.g., `DEFAULT CURRENT_TIMESTAMP` in MySQL, `DEFAULT now()` in PostgreSQL)
|
|
776
|
+
description: annotationExample("Current timestamp default. Each adapter maps this to its native mechanism (e.g., `DEFAULT CURRENT_TIMESTAMP` in MySQL, `DEFAULT now()` in PostgreSQL).", "@db.default.now", "createdAt: number.timestamp"),
|
|
833
777
|
nodeType: ["prop"],
|
|
834
778
|
passedWhenReferred: false,
|
|
835
779
|
validate(token, args, doc) {
|
|
@@ -837,23 +781,34 @@ const dbColumnAnnotations = {
|
|
|
837
781
|
}
|
|
838
782
|
})
|
|
839
783
|
},
|
|
840
|
-
onUpdate: {
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
784
|
+
onUpdate: {
|
|
785
|
+
now: new AnnotationSpec({
|
|
786
|
+
description: annotationExample("Sets the field to the current time (epoch ms) on every update: patches (`updateOne`, `bulkUpdate`, `updateMany`, HTTP `PATCH`) and replaces (`replaceOne`, `bulkReplace`, `replaceMany`, HTTP `PUT`). The value is written by the SDK in the same statement on every adapter and overrides a value the payload carries. Inserts are not affected — combine with `@db.default.now` (`number.timestamp.updated` applies both). Writes outside the SDK (raw SQL) do not set it.", "@db.default.now", "@db.onUpdate.now", "updatedAt: number.timestamp"),
|
|
787
|
+
nodeType: ["prop"],
|
|
788
|
+
passedWhenReferred: false,
|
|
789
|
+
validate(token, args, doc) {
|
|
790
|
+
const errors = validateFieldBaseType(token, doc, "@db.onUpdate.now", "number");
|
|
791
|
+
const field = token.parentNode;
|
|
792
|
+
for (const [name, why] of [["meta.id", "the primary key identifies the row and is never updated"], ["db.column.version", "the version column is adapter-managed"]]) if (field.countAnnotations(name) > 0) errors.push({
|
|
793
|
+
message: `@db.onUpdate.now cannot coexist with @${name} — ${why}`,
|
|
794
|
+
severity: 1,
|
|
795
|
+
range: token.range
|
|
796
|
+
});
|
|
797
|
+
return errors;
|
|
798
|
+
}
|
|
799
|
+
}),
|
|
800
|
+
inc: new AnnotationSpec({
|
|
801
|
+
description: annotationExample("A per-row **change counter**: `0` on insert (unless the insert supplies a value), then `+1` in the same statement on every SDK update of the row — patches (`updateOne`, `bulkUpdate`, `updateMany`, HTTP `PATCH`, including patches that write only `@db.column.version.exempt` fields), replaces (`replaceOne`, `bulkReplace`, `replaceMany`, HTTP `PUT`) and `touchMany`. A value the payload carries is ignored, so clients cannot set it. An empty patch writes nothing and leaves it unchanged. It has no compare-and-set and does not affect `@db.column.version`. Writes outside the SDK (raw SQL, native `ON DELETE SET NULL` actions) do not move it.\n\n**Constraints:** a non-optional integer field at the top level of the table; not the primary key, the version column, a `@db.onUpdate.now`, `@db.default*`, `@db.json` or `@db.encrypted` field.", "@db.onUpdate.inc", "revision: number.int"),
|
|
802
|
+
nodeType: ["prop"],
|
|
803
|
+
passedWhenReferred: false,
|
|
804
|
+
multiple: false,
|
|
805
|
+
validate(token, _args, doc) {
|
|
806
|
+
return validateOnUpdateInc(token, doc);
|
|
807
|
+
}
|
|
808
|
+
})
|
|
809
|
+
},
|
|
855
810
|
json: new AnnotationSpec({
|
|
856
|
-
description: "Forces a field to be stored as a single JSON column instead of being flattened into separate columns. Use on nested object fields that should remain as JSON in the database
|
|
811
|
+
description: annotationExample("Forces a field to be stored as a single JSON column instead of being flattened into separate columns. Use on nested object fields that should remain as JSON in the database. On a primitive field (string / number / boolean) it stores the value as JSON too: SQL adapters filter the column only with `$exists` and never sort it — drop `@db.json` to get a typed, sortable scalar column.", "@db.json", "metadata: { key: string, value: string }"),
|
|
857
812
|
nodeType: ["prop"],
|
|
858
813
|
passedWhenReferred: false,
|
|
859
814
|
validate(token, _args, doc) {
|
|
@@ -862,7 +817,7 @@ const dbColumnAnnotations = {
|
|
|
862
817
|
if (definition && isRef(definition)) {
|
|
863
818
|
const unwound = doc.unwindType(definition.id, definition.chain);
|
|
864
819
|
if (unwound && isPrimitive(unwound.def)) errors.push({
|
|
865
|
-
message: "@db.json on a primitive field
|
|
820
|
+
message: "@db.json on a primitive field stores it as one JSON column — filterable only with $exists and never sortable; drop @db.json for a typed scalar column",
|
|
866
821
|
severity: 2,
|
|
867
822
|
range: token.range
|
|
868
823
|
});
|
|
@@ -871,7 +826,7 @@ const dbColumnAnnotations = {
|
|
|
871
826
|
}
|
|
872
827
|
}),
|
|
873
828
|
ignore: new AnnotationSpec({
|
|
874
|
-
description: "Excludes a field from the database schema. The field exists in the Atscript type but has no column in the DB
|
|
829
|
+
description: annotationExample("Excludes a field from the database schema. The field exists in the Atscript type but has no column in the DB.", "@db.ignore", "displayName: string"),
|
|
875
830
|
nodeType: ["prop"],
|
|
876
831
|
passedWhenReferred: false,
|
|
877
832
|
validate(token, _args, _doc) {
|
|
@@ -885,38 +840,18 @@ const dbColumnAnnotations = {
|
|
|
885
840
|
}
|
|
886
841
|
}),
|
|
887
842
|
encrypted: new AnnotationSpec({
|
|
888
|
-
description: "Encrypts this field at rest. Values are AES-256-GCM encrypted by the core layer before reaching the database and decrypted on read — transparent to application code.\n\nConstraints: the field cannot be filtered, sorted, indexed, used as PK/FK, patched with arithmetic ops, or referenced by search/vector/geo features
|
|
843
|
+
description: annotationExample("Encrypts this field at rest. Values are AES-256-GCM encrypted by the core layer before reaching the database and decrypted on read — transparent to application code.\n\nConstraints: the field cannot be filtered, sorted, indexed, used as PK/FK, patched with arithmetic ops, or referenced by search/vector/geo features.", "@db.encrypted", "apiToken?: string"),
|
|
889
844
|
nodeType: ["prop"],
|
|
890
845
|
passedWhenReferred: false,
|
|
891
846
|
multiple: false,
|
|
892
|
-
validate(token, _args,
|
|
893
|
-
const
|
|
894
|
-
const
|
|
895
|
-
|
|
896
|
-
["meta.id", "the primary key must be addressable"],
|
|
897
|
-
["db.rel.FK", "joins are impossible over ciphertext"],
|
|
898
|
-
["db.index.plain", "indexes over ciphertext are meaningless"],
|
|
899
|
-
["db.index.unique", "indexes over ciphertext are meaningless"],
|
|
900
|
-
["db.index.fulltext", "indexes over ciphertext are meaningless"],
|
|
901
|
-
["db.index.geo", "geo indexes over ciphertext are meaningless"],
|
|
902
|
-
["db.search.vector", "vector search over ciphertext is impossible"],
|
|
903
|
-
["db.search.filter", "search filters need cleartext equality"],
|
|
904
|
-
["db.column.version", "the OCC filter needs cleartext equality"],
|
|
905
|
-
["db.default.increment", "engine-side defaults bypass the encryption transform"],
|
|
906
|
-
["db.default.now", "engine-side defaults bypass the encryption transform"],
|
|
907
|
-
["db.onUpdate.now", "the update time is set after the encryption transform"],
|
|
908
|
-
["db.mongo.search.text", "Atlas Search over ciphertext is impossible"],
|
|
909
|
-
["db.mongo.search.autocomplete", "Atlas Search over ciphertext is impossible"]
|
|
910
|
-
]) if (field.countAnnotations(name) > 0) errors.push({
|
|
911
|
-
message: `@db.encrypted cannot coexist with @${name} — ${why}`,
|
|
912
|
-
severity: 1,
|
|
913
|
-
range: token.range
|
|
914
|
-
});
|
|
915
|
-
return errors;
|
|
847
|
+
validate(token, _args, doc) {
|
|
848
|
+
const typeProblem = encryptedTypeSearchProblem(token, doc);
|
|
849
|
+
const errors = validateIncompatible(token, "@db.encrypted", ENCRYPTED_INCOMPATIBLE, doc);
|
|
850
|
+
return typeProblem ? [typeProblem, ...errors] : errors;
|
|
916
851
|
}
|
|
917
852
|
}),
|
|
918
853
|
writeOnly: new AnnotationSpec({
|
|
919
|
-
description: "Marks a field as write-only over HTTP: it may be set through insert/update/replace payloads but NEVER appears in read responses — the readable controller excludes it from every projection, rejects filtering/sorting/grouping on it, and `/meta` serves its TYPE (flagged `writeOnly`) so client preflight validation and generated forms still know its shape. The classic case is a sealed secret (pair with `@db.encrypted`): writable via generic forms, unreadable by anyone.\n\nServer-side code reading through `AtscriptDbTable` still sees the value — the seal is an HTTP-layer contract, not a storage one. It cannot be combined with `@db.index.fulltext` or `@db.column.searchable`: a search hit would reveal the sealed value
|
|
854
|
+
description: annotationExample("Marks a field as write-only over HTTP: it may be set through insert/update/replace payloads but NEVER appears in read responses — the readable controller excludes it from every projection, rejects filtering/sorting/grouping on it, and `/meta` serves its TYPE (flagged `writeOnly`) so client preflight validation and generated forms still know its shape. The classic case is a sealed secret (pair with `@db.encrypted`): writable via generic forms, unreadable by anyone.\n\nServer-side code reading through `AtscriptDbTable` still sees the value — the seal is an HTTP-layer contract, not a storage one. It cannot be combined with `@db.index.fulltext` or `@db.column.searchable`: a search hit would reveal the sealed value.", "@db.writeOnly", "@db.encrypted", "credentials?: string"),
|
|
920
855
|
nodeType: ["prop"],
|
|
921
856
|
passedWhenReferred: false,
|
|
922
857
|
multiple: false,
|
|
@@ -938,15 +873,9 @@ const dbColumnAnnotations = {
|
|
|
938
873
|
})
|
|
939
874
|
};
|
|
940
875
|
function columnCapability(capability, verb) {
|
|
941
|
-
const example = capability === "filterable" ? " @db.column.filterable
|
|
876
|
+
const example = capability === "filterable" ? [" @db.column.filterable", " email: string"] : [" @db.column.sortable", " createdAt: number.timestamp"];
|
|
942
877
|
return new AnnotationSpec({
|
|
943
|
-
description: `Marks a column as ${capability} in the readable controller's query/pages endpoints. Relevant only when the host \`@db.table\` interface opts into strict mode with \`@db.table.${capability} 'manual'\`; otherwise ${verb} is open on every column the adapter can handle (default-open, back-compat) and the annotation is a no-op. Adapter capability always wins: \`@db.json\` / array columns stay non-sortable (and non-filterable on SQL adapters) even when annotated. \`/meta.fields\` advertises exactly what the gate accepts; index-backed columns additionally carry the advisory \`indexed\` flag.
|
|
944
|
-
|
|
945
|
-
**Example:**
|
|
946
|
-
\`\`\`atscript
|
|
947
|
-
@db.table "users"
|
|
948
|
-
@db.table.${capability} "manual"\nexport interface User {
|
|
949
|
-
` + example + "}\n```\n",
|
|
878
|
+
description: annotationExample(`Marks a column as ${capability} in the readable controller's query/pages endpoints. Relevant only when the host \`@db.table\` interface opts into strict mode with \`@db.table.${capability} 'manual'\`; otherwise ${verb} is open on every column the adapter can handle (default-open, back-compat) and the annotation is a no-op. Adapter capability always wins: \`@db.json\` / array columns stay non-sortable (and non-filterable on SQL adapters) even when annotated. \`/meta.fields\` advertises exactly what the gate accepts; index-backed columns additionally carry the advisory \`indexed\` flag.`, "@db.table \"users\"", `@db.table.${capability} "manual"`, "export interface User {", ...example, "}"),
|
|
950
879
|
nodeType: ["prop"],
|
|
951
880
|
passedWhenReferred: false,
|
|
952
881
|
multiple: false,
|
|
@@ -1045,7 +974,7 @@ function validateCompute(token, args, doc) {
|
|
|
1045
974
|
error("@db.compute is only valid on a field of a @db.view.for view");
|
|
1046
975
|
return errors;
|
|
1047
976
|
}
|
|
1048
|
-
errors.push(...validateExclusiveWith(token, "@db.compute", EXCLUSIVE_WITH));
|
|
977
|
+
errors.push(...validateExclusiveWith(token, "@db.compute", EXCLUSIVE_WITH, doc));
|
|
1049
978
|
if (!isPlainNumber(prop, doc)) error(`Field "${field}" has a @db.compute and must be typed \`number\``);
|
|
1050
979
|
const expr = args[0]?.exprNode;
|
|
1051
980
|
if (!expr) return errors;
|
|
@@ -1068,7 +997,7 @@ function validateCompute(token, args, doc) {
|
|
|
1068
997
|
return errors;
|
|
1069
998
|
}
|
|
1070
999
|
const dbComputeAnnotations = { compute: new AnnotationSpec({
|
|
1071
|
-
description: "Declares a **computed view column** (since 0.1.147): closed arithmetic over the view's own fields — dimensions, aggregates, first-row-join fields and other computed fields — evaluated by the database, so the column sorts, filters and pages like any other.\n\nGrammar: `+ - * /`, unary `-`, parentheses, numeric literals, `coalesce(a, b, …)`. Every value is a double (`7 / 2 = 3.5`); a NULL operand yields NULL; division by zero yields NULL — so an expression with `/` or an optional operand needs an optional field
|
|
1000
|
+
description: annotationExample("Declares a **computed view column** (since 0.1.147): closed arithmetic over the view's own fields — dimensions, aggregates, first-row-join fields and other computed fields — evaluated by the database, so the column sorts, filters and pages like any other.\n\nGrammar: `+ - * /`, unary `-`, parentheses, numeric literals, `coalesce(a, b, …)`. Every value is a double (`7 / 2 = 3.5`); a NULL operand yields NULL; division by zero yields NULL — so an expression with `/` or an optional operand needs an optional field.", "@db.agg.count 'id', `Issue.status = 'open'`", "openCount: Issue.id", "", "@db.compute `openCount * 10 + coalesce(oldestSeverity, 0)`", "rank: number"),
|
|
1072
1001
|
nodeType: ["prop"],
|
|
1073
1002
|
passedWhenReferred: false,
|
|
1074
1003
|
argument: {
|
|
@@ -1083,7 +1012,7 @@ const dbComputeAnnotations = { compute: new AnnotationSpec({
|
|
|
1083
1012
|
//#region src/plugin/annotations/index-ann.ts
|
|
1084
1013
|
const dbIndexAnnotations = { index: {
|
|
1085
1014
|
plain: new AnnotationSpec({
|
|
1086
|
-
description: "Standard (non-unique) index for query performance. Fields sharing the same index name form a composite index
|
|
1015
|
+
description: annotationExample("Standard (non-unique) index for query performance. Fields sharing the same index name form a composite index.", "@db.index.plain \"idx_timeline\", \"desc\"", "createdAt: number.timestamp"),
|
|
1087
1016
|
nodeType: ["prop"],
|
|
1088
1017
|
passedWhenReferred: false,
|
|
1089
1018
|
multiple: true,
|
|
@@ -1102,7 +1031,7 @@ const dbIndexAnnotations = { index: {
|
|
|
1102
1031
|
}]
|
|
1103
1032
|
}),
|
|
1104
1033
|
unique: new AnnotationSpec({
|
|
1105
|
-
description: "Unique index — ensures no two rows/documents have the same value(s). Fields sharing the same index name form a composite unique constraint
|
|
1034
|
+
description: annotationExample("Unique index — ensures no two rows/documents have the same value(s). Fields sharing the same index name form a composite unique constraint.", "@db.index.unique \"tenant_email\"", "email: string.email"),
|
|
1106
1035
|
nodeType: ["prop"],
|
|
1107
1036
|
passedWhenReferred: false,
|
|
1108
1037
|
multiple: true,
|
|
@@ -1115,9 +1044,11 @@ const dbIndexAnnotations = { index: {
|
|
|
1115
1044
|
}
|
|
1116
1045
|
}),
|
|
1117
1046
|
fulltext: new AnnotationSpec({
|
|
1118
|
-
description: "Full-text search index. Fields sharing the same index name form a composite full-text index. A string member is part of the engine's text index. An integer member (`number.int` and its sizes, `@expect.int`, `@db.default.increment`) is never part of the physical text index: when the whole search term is a whole number (`2946`, `-12`; no leading zeros), rows whose member equals it exactly also match (OR'd with the text match). An integer member must be index-backed — the primary key (first `@meta.id`) or the first field of a `@db.index.plain` / `@db.index.unique`. Floats, decimals and timestamps are refused
|
|
1047
|
+
description: annotationExample("Full-text search index. Fields sharing the same index name form a composite full-text index. A string member is part of the engine's text index. An integer member (`number.int` and its sizes, `@expect.int`, `@db.default.increment`) is never part of the physical text index: when the whole search term is a whole number (`2946`, `-12`; no leading zeros), rows whose member equals it exactly also match (OR'd with the text match). An integer member must be index-backed — the primary key (first `@meta.id`) or the first field of a `@db.index.plain` / `@db.index.unique`. Floats, decimals and timestamps are refused.", "@db.index.fulltext \"ft_content\"", "title: string", "", "@db.index.fulltext \"ft_content\", 5", "bio: string", "", "@db.index.fulltext \"ft_content\"", "@db.index.unique", "refNo: number.int"),
|
|
1119
1048
|
validate(token, args, doc) {
|
|
1120
1049
|
const field = token.parentNode;
|
|
1050
|
+
const sealed = sealedSearchProblem(token, doc, "@db.index.fulltext");
|
|
1051
|
+
if (sealed) return [sealed];
|
|
1121
1052
|
const verdict = searchFieldVerdict(field, doc);
|
|
1122
1053
|
if ("problem" in verdict) return [{
|
|
1123
1054
|
message: `@db.index.fulltext needs a string or an integer field — "${field.id}" ${verdict.problem}`,
|
|
@@ -1156,7 +1087,7 @@ const dbIndexAnnotations = { index: {
|
|
|
1156
1087
|
}]
|
|
1157
1088
|
}),
|
|
1158
1089
|
geo: new AnnotationSpec({
|
|
1159
|
-
description: "Geospatial index on a `db.geoPoint` field. Enables `geoSearch()` (distance-ranked) and accelerates `$geoWithin` filters
|
|
1090
|
+
description: annotationExample("Geospatial index on a `db.geoPoint` field. Enables `geoSearch()` (distance-ranked) and accelerates `$geoWithin` filters.", "@db.index.geo", "geo: db.geoPoint"),
|
|
1160
1091
|
nodeType: ["prop"],
|
|
1161
1092
|
passedWhenReferred: false,
|
|
1162
1093
|
multiple: false,
|
|
@@ -1165,7 +1096,8 @@ const dbIndexAnnotations = { index: {
|
|
|
1165
1096
|
name: "name",
|
|
1166
1097
|
type: "string",
|
|
1167
1098
|
description: "Index name. Defaults to the field name."
|
|
1168
|
-
}
|
|
1099
|
+
},
|
|
1100
|
+
validate: refuseCiphertext("@db.index.geo")
|
|
1169
1101
|
})
|
|
1170
1102
|
} };
|
|
1171
1103
|
//#endregion
|
|
@@ -1197,7 +1129,7 @@ function checkCompositeFkCoverage(token, alias, refTypeName, targetStruct, targe
|
|
|
1197
1129
|
}
|
|
1198
1130
|
const dbRelAnnotations = { rel: {
|
|
1199
1131
|
FK: new AnnotationSpec({
|
|
1200
|
-
description: "Declares a foreign key reference on this field. The field must use a chain reference type (e.g., `User.id`) whose target is a primary key (`@meta.id`) or unique (`@db.index.unique`) field.\n\n**Dual role:**\n- On a `@db.table` interface, `@db.rel.FK` additionally drives DB-relation semantics — relation loading with `@db.rel.to` / `@db.rel.from`, junction pairing with `@db.rel.via`, etc.\n- On any other interface (value-help sources, WF forms, plain interfaces), `@db.rel.FK` acts purely as the value-help indicator: the client-side picker resolver uses it to decide which fields render a value-help picker. The target's `@db.http.path` (published by its canonical readable controller) supplies the picker URL.\n- In `/meta` (and `/meta/form/:name`) the marker is inherited through reference chains: a field declared as `code: Issue.code` where `Issue.code: Dict.code` carries `@db.rel.FK` is served with `ref` pointing at the terminal field (`Dict.code`) and `db.rel.FK: true`, so view fields get the dictionary picker. Runtime metadata is untouched
|
|
1132
|
+
description: annotationExample("Declares a foreign key reference on this field. The field must use a chain reference type (e.g., `User.id`) whose target is a primary key (`@meta.id`) or unique (`@db.index.unique`) field.\n\n**Dual role:**\n- On a `@db.table` interface, `@db.rel.FK` additionally drives DB-relation semantics — relation loading with `@db.rel.to` / `@db.rel.from`, junction pairing with `@db.rel.via`, etc.\n- On any other interface (value-help sources, WF forms, plain interfaces), `@db.rel.FK` acts purely as the value-help indicator: the client-side picker resolver uses it to decide which fields render a value-help picker. The target's `@db.http.path` (published by its canonical readable controller) supplies the picker URL.\n- In `/meta` (and `/meta/form/:name`) the marker is inherited through reference chains: a field declared as `code: Issue.code` where `Issue.code: Dict.code` carries `@db.rel.FK` is served with `ref` pointing at the terminal field (`Dict.code`) and `db.rel.FK: true`, so view fields get the dictionary picker. Runtime metadata is untouched.", "@db.rel.FK", "authorId: User.id", "", "// With alias (required when multiple FKs point to the same type)", "@db.rel.FK \"author\"", "authorId: User.id"),
|
|
1201
1133
|
nodeType: ["prop"],
|
|
1202
1134
|
passedWhenReferred: false,
|
|
1203
1135
|
argument: {
|
|
@@ -1287,7 +1219,7 @@ const dbRelAnnotations = { rel: {
|
|
|
1287
1219
|
}
|
|
1288
1220
|
}),
|
|
1289
1221
|
to: new AnnotationSpec({
|
|
1290
|
-
description: "Forward navigational property — the FK is on **this** interface. The compiler resolves the matching @db.rel.FK by target type or alias
|
|
1222
|
+
description: annotationExample("Forward navigational property — the FK is on **this** interface. The compiler resolves the matching @db.rel.FK by target type or alias.", "@db.rel.to", "author?: User", "", "// With alias", "@db.rel.to \"author\"", "author?: User"),
|
|
1291
1223
|
nodeType: ["prop"],
|
|
1292
1224
|
passedWhenReferred: false,
|
|
1293
1225
|
argument: {
|
|
@@ -1371,7 +1303,7 @@ const dbRelAnnotations = { rel: {
|
|
|
1371
1303
|
}
|
|
1372
1304
|
}),
|
|
1373
1305
|
from: new AnnotationSpec({
|
|
1374
|
-
description: "Inverse navigational property — the FK is on the **target** interface, pointing back to this one
|
|
1306
|
+
description: annotationExample("Inverse navigational property — the FK is on the **target** interface, pointing back to this one.", "@db.rel.from", "posts: Post[]", "", "// With alias", "@db.rel.from \"original\"", "comments: Comment[]"),
|
|
1375
1307
|
nodeType: ["prop"],
|
|
1376
1308
|
passedWhenReferred: false,
|
|
1377
1309
|
argument: {
|
|
@@ -1460,7 +1392,7 @@ const dbRelAnnotations = { rel: {
|
|
|
1460
1392
|
onDelete: refActionAnnotation("onDelete"),
|
|
1461
1393
|
onUpdate: refActionAnnotation("onUpdate"),
|
|
1462
1394
|
via: new AnnotationSpec({
|
|
1463
|
-
description: "Declares a many-to-many navigational property through an explicit junction table. `@db.rel.via` is self-sufficient — no `@db.rel.from` pairing is needed
|
|
1395
|
+
description: annotationExample("Declares a many-to-many navigational property through an explicit junction table. `@db.rel.via` is self-sufficient — no `@db.rel.from` pairing is needed.", "@db.rel.via PostTag", "tags: Tag[]"),
|
|
1464
1396
|
nodeType: ["prop"],
|
|
1465
1397
|
passedWhenReferred: false,
|
|
1466
1398
|
argument: {
|
|
@@ -1524,7 +1456,7 @@ const dbRelAnnotations = { rel: {
|
|
|
1524
1456
|
}
|
|
1525
1457
|
}),
|
|
1526
1458
|
filterable: new AnnotationSpec({
|
|
1527
|
-
description: "Lets HTTP clients filter the parent rows by this relation's related rows — `ticket=$some(status=open)` / `ticket=$none(...)` in a moost-db query (`{ ticket: { $some: { status: 'open' } } }`). Off by default: a client predicate filters the PARENT set and runs a correlated subquery. Server-side code (`transformFilter`, `actionRowScope`, direct table calls) never needs it. The related table's own field rules (visibility, encryption, write-only) apply inside the predicate
|
|
1459
|
+
description: annotationExample("Lets HTTP clients filter the parent rows by this relation's related rows — `ticket=$some(status=open)` / `ticket=$none(...)` in a moost-db query (`{ ticket: { $some: { status: 'open' } } }`). Off by default: a client predicate filters the PARENT set and runs a correlated subquery. Server-side code (`transformFilter`, `actionRowScope`, direct table calls) never needs it. The related table's own field rules (visibility, encryption, write-only) apply inside the predicate.", "@db.rel.to", "@db.rel.filterable", "ticket?: Ticket"),
|
|
1528
1460
|
nodeType: ["prop"],
|
|
1529
1461
|
passedWhenReferred: false,
|
|
1530
1462
|
validate(token) {
|
|
@@ -1539,7 +1471,7 @@ const dbRelAnnotations = { rel: {
|
|
|
1539
1471
|
}
|
|
1540
1472
|
}),
|
|
1541
1473
|
filter: new AnnotationSpec({
|
|
1542
|
-
description: "Applies a filter to a navigational property, restricting which related records are loaded (`$with`) and which count in relational predicates (`$some` / `$none`)
|
|
1474
|
+
description: annotationExample("Applies a filter to a navigational property, restricting which related records are loaded (`$with`) and which count in relational predicates (`$some` / `$none`).", "@db.rel.from", "@db.rel.filter `Post.published = true`", "publishedPosts: Post[]"),
|
|
1543
1475
|
nodeType: ["prop"],
|
|
1544
1476
|
passedWhenReferred: false,
|
|
1545
1477
|
argument: {
|
|
@@ -1604,8 +1536,9 @@ function forEachComparison(expr, fn) {
|
|
|
1604
1536
|
const dbSearchAnnotations = { search: {
|
|
1605
1537
|
vector: {
|
|
1606
1538
|
$self: new AnnotationSpec({
|
|
1607
|
-
description: "Marks a field as a **vector embedding** for **similarity search**.\n\n- Each adapter maps this to its native vector type and index:\n - **MongoDB** → Atlas `$vectorSearch` index\n - **MySQL 9+** → `VECTOR(N)` column + `VEC_DISTANCE_*` functions\n - **PostgreSQL** → pgvector `vector(N)` column + distance operators\n - **SQLite** → `BLOB` column + `vec0` virtual index (requires the `sqlite-vec` extension; falls back to JSON `TEXT` when unavailable)
|
|
1539
|
+
description: annotationExample("Marks a field as a **vector embedding** for **similarity search**.\n\n- Each adapter maps this to its native vector type and index:\n - **MongoDB** → Atlas `$vectorSearch` index\n - **MySQL 9+** → `VECTOR(N)` column + `VEC_DISTANCE_*` functions\n - **PostgreSQL** → pgvector `vector(N)` column + distance operators\n - **SQLite** → `BLOB` column + `vec0` virtual index (requires the `sqlite-vec` extension; falls back to JSON `TEXT` when unavailable)", "@db.search.vector 1536, \"cosine\"", "embedding: db.vector"),
|
|
1608
1540
|
nodeType: ["prop"],
|
|
1541
|
+
validate: refuseCiphertext("@db.search.vector"),
|
|
1609
1542
|
passedWhenReferred: false,
|
|
1610
1543
|
multiple: false,
|
|
1611
1544
|
argument: [
|
|
@@ -1649,7 +1582,7 @@ const dbSearchAnnotations = { search: {
|
|
|
1649
1582
|
]
|
|
1650
1583
|
}),
|
|
1651
1584
|
threshold: new AnnotationSpec({
|
|
1652
|
-
description: "Sets a **default minimum similarity threshold** for vector search queries on this field.\n\n- Results with a similarity score below this threshold are excluded.\n- Query-time `$threshold` control overrides this default.\n- Value range: `0` to `1` (where `1` means exact match)
|
|
1585
|
+
description: annotationExample("Sets a **default minimum similarity threshold** for vector search queries on this field.\n\n- Results with a similarity score below this threshold are excluded.\n- Query-time `$threshold` control overrides this default.\n- Value range: `0` to `1` (where `1` means exact match).", "@db.search.vector 1536, \"cosine\"", "@db.search.vector.threshold 0.7", "embedding: db.vector"),
|
|
1653
1586
|
nodeType: ["prop"],
|
|
1654
1587
|
passedWhenReferred: false,
|
|
1655
1588
|
multiple: false,
|
|
@@ -1662,8 +1595,9 @@ const dbSearchAnnotations = { search: {
|
|
|
1662
1595
|
})
|
|
1663
1596
|
},
|
|
1664
1597
|
filter: new AnnotationSpec({
|
|
1665
|
-
description: "Assigns a field as a **pre-filter** for a **vector search index**.\n\n- Filters allow vector search queries to return results only within a specific subset.\n- The referenced index must be defined using `@db.search.vector
|
|
1598
|
+
description: annotationExample("Assigns a field as a **pre-filter** for a **vector search index**.\n\n- Filters allow vector search queries to return results only within a specific subset.\n- The referenced index must be defined using `@db.search.vector`.", "@db.search.vector 1536, \"cosine\"", "embedding: db.vector", "", "@db.search.filter \"embedding\"", "category: string"),
|
|
1666
1599
|
nodeType: ["prop"],
|
|
1600
|
+
validate: refuseCiphertext("@db.search.filter"),
|
|
1667
1601
|
passedWhenReferred: false,
|
|
1668
1602
|
multiple: true,
|
|
1669
1603
|
argument: {
|
|
@@ -1681,7 +1615,7 @@ const dbSearchAnnotations = { search: {
|
|
|
1681
1615
|
* read sorts by it without a `$nulls` entry for it.
|
|
1682
1616
|
*/
|
|
1683
1617
|
const dbSortAnnotations = { sort: { nulls: new AnnotationSpec({
|
|
1684
|
-
description: "Default **NULL placement** when a read sorts by this field and the request names none (`$nulls` control, URL suffix `$sort=-closedAt:last`).\n\n- **`'first'`** — rows where the field is NULL (or missing) come before every value, in both sort directions\n- **`'last'`** — they come after every value, in both directions\n\nWithout it (and without `$nulls`) NULL placement is the database's own: first in ascending order on SQLite, MySQL, MongoDB and the in-memory adapter, last on PostgreSQL. Applies to `$sort` and to the `$rowOrder` of `first()` / `last()`. Only optional and `| null` fields can be NULL — on another required table field it has no effect. On PostgreSQL, MySQL and MongoDB a placement the engine does not produce natively costs the index its ORDER BY (a sort step instead)
|
|
1618
|
+
description: annotationExample("Default **NULL placement** when a read sorts by this field and the request names none (`$nulls` control, URL suffix `$sort=-closedAt:last`).\n\n- **`'first'`** — rows where the field is NULL (or missing) come before every value, in both sort directions\n- **`'last'`** — they come after every value, in both directions\n\nWithout it (and without `$nulls`) NULL placement is the database's own: first in ascending order on SQLite, MySQL, MongoDB and the in-memory adapter, last on PostgreSQL. Applies to `$sort` and to the `$rowOrder` of `first()` / `last()`. Only optional and `| null` fields can be NULL — on another required table field it has no effect. On PostgreSQL, MySQL and MongoDB a placement the engine does not produce natively costs the index its ORDER BY (a sort step instead).", "@db.sort.nulls 'last'", "closedAt?: number.timestamp"),
|
|
1685
1619
|
nodeType: ["prop"],
|
|
1686
1620
|
passedWhenReferred: false,
|
|
1687
1621
|
multiple: false,
|
|
@@ -1707,6 +1641,10 @@ const dbSortAnnotations = { sort: { nulls: new AnnotationSpec({
|
|
|
1707
1641
|
fail("@db.sort.nulls on a @db.encrypted field — encrypted fields are not sortable");
|
|
1708
1642
|
return errors;
|
|
1709
1643
|
}
|
|
1644
|
+
if (viewFieldSeals(field, doc).encrypted) {
|
|
1645
|
+
fail("@db.sort.nulls on a view field over a @db.encrypted source field — encrypted fields are not sortable");
|
|
1646
|
+
return errors;
|
|
1647
|
+
}
|
|
1710
1648
|
const def = field.getDefinition();
|
|
1711
1649
|
let leaf = def;
|
|
1712
1650
|
if (def && isRef(def)) leaf = doc.unwindType(def.id, def.chain)?.def;
|
|
@@ -1727,7 +1665,7 @@ const dbTableAnnotations = {
|
|
|
1727
1665
|
filterable: tableCapability("filterable"),
|
|
1728
1666
|
sortable: tableCapability("sortable"),
|
|
1729
1667
|
$self: new AnnotationSpec({
|
|
1730
|
-
description: "Marks an interface as a database-persisted entity (table in SQL, collection in MongoDB). If the name argument is omitted, the adapter derives the table name from the interface name
|
|
1668
|
+
description: annotationExample("Marks an interface as a database-persisted entity (table in SQL, collection in MongoDB). If the name argument is omitted, the adapter derives the table name from the interface name.", "@db.table \"users\"", "export interface User { ... }"),
|
|
1731
1669
|
nodeType: ["interface"],
|
|
1732
1670
|
passedWhenReferred: false,
|
|
1733
1671
|
argument: {
|
|
@@ -1748,7 +1686,7 @@ const dbTableAnnotations = {
|
|
|
1748
1686
|
}
|
|
1749
1687
|
}),
|
|
1750
1688
|
renamed: new AnnotationSpec({
|
|
1751
|
-
description: "Specifies the previous table name for table rename migration. The sync engine generates ALTER TABLE RENAME instead of drop+create
|
|
1689
|
+
description: annotationExample("Specifies the previous table name for table rename migration. The sync engine generates ALTER TABLE RENAME instead of drop+create.", "@db.table \"app_users\"", "@db.table.renamed \"users\"", "export interface User { ... }"),
|
|
1752
1690
|
nodeType: ["interface"],
|
|
1753
1691
|
passedWhenReferred: false,
|
|
1754
1692
|
argument: {
|
|
@@ -1767,7 +1705,7 @@ const dbTableAnnotations = {
|
|
|
1767
1705
|
}
|
|
1768
1706
|
}),
|
|
1769
1707
|
preferredId: { uniqueIndex: new AnnotationSpec({
|
|
1770
|
-
description: "Selects a unique index as the table's preferred row identifier. When the unique-index name is omitted, the first declared unique-index group wins
|
|
1708
|
+
description: annotationExample("Selects a unique index as the table's preferred row identifier. When the unique-index name is omitted, the first declared unique-index group wins.", "@db.table", "@db.table.preferredId.uniqueIndex \"by_slug\"", "export interface Post {", " @db.index.unique \"by_slug\"", " slug: string", "}"),
|
|
1771
1709
|
nodeType: ["interface"],
|
|
1772
1710
|
passedWhenReferred: false,
|
|
1773
1711
|
multiple: false,
|
|
@@ -1815,7 +1753,7 @@ const dbTableAnnotations = {
|
|
|
1815
1753
|
}) }
|
|
1816
1754
|
},
|
|
1817
1755
|
schema: new AnnotationSpec({
|
|
1818
|
-
description: "Assigns the entity to a database schema/namespace
|
|
1756
|
+
description: annotationExample("Assigns the entity to a database schema/namespace.", "@db.table \"users\"", "@db.schema \"auth\"", "export interface User { ... }"),
|
|
1819
1757
|
nodeType: ["interface"],
|
|
1820
1758
|
passedWhenReferred: false,
|
|
1821
1759
|
argument: {
|
|
@@ -1825,7 +1763,7 @@ const dbTableAnnotations = {
|
|
|
1825
1763
|
}
|
|
1826
1764
|
}),
|
|
1827
1765
|
space: new AnnotationSpec({
|
|
1828
|
-
description: "Binds the entity to a named database space (a `DbSpace` — one per physical database) for apps running more than one database at once (e.g. MongoDB + PostgreSQL). Absent means the default space.\n\nConsumed by the generated model manifest (grouping in `modelsBySpace`) and by `@TableController(Model)` token binding, which resolves the space registered under this name via `provideDbSpace(space, name)
|
|
1766
|
+
description: annotationExample("Binds the entity to a named database space (a `DbSpace` — one per physical database) for apps running more than one database at once (e.g. MongoDB + PostgreSQL). Absent means the default space.\n\nConsumed by the generated model manifest (grouping in `modelsBySpace`) and by `@TableController(Model)` token binding, which resolves the space registered under this name via `provideDbSpace(space, name)`.", "@db.table \"feed_runs\"", "@db.space \"analytics\"", "export interface FeedRun { ... }"),
|
|
1829
1767
|
nodeType: ["interface"],
|
|
1830
1768
|
passedWhenReferred: false,
|
|
1831
1769
|
argument: {
|
|
@@ -1835,7 +1773,7 @@ const dbTableAnnotations = {
|
|
|
1835
1773
|
}
|
|
1836
1774
|
}),
|
|
1837
1775
|
http: { path: new AnnotationSpec({
|
|
1838
|
-
description: "HTTP endpoint path where this table is served. Used by the UI for value-help on FK fields. After app.init() the app publishes the canonical controller route here at runtime; a model mounted on several routes needs `canonical: true` on one of its controllers (or declare this annotation and mount that controller without an explicit prefix)
|
|
1776
|
+
description: annotationExample("HTTP endpoint path where this table is served. Used by the UI for value-help on FK fields. After app.init() the app publishes the canonical controller route here at runtime; a model mounted on several routes needs `canonical: true` on one of its controllers (or declare this annotation and mount that controller without an explicit prefix).", "@db.table \"authors\"", "@db.http.path \"/authors\"", "export interface Author { ... }"),
|
|
1839
1777
|
nodeType: ["interface"],
|
|
1840
1778
|
passedWhenReferred: false,
|
|
1841
1779
|
argument: {
|
|
@@ -1845,7 +1783,7 @@ const dbTableAnnotations = {
|
|
|
1845
1783
|
}
|
|
1846
1784
|
}) },
|
|
1847
1785
|
sync: { method: new AnnotationSpec({
|
|
1848
|
-
description: "Controls how the sync engine handles structural changes that cannot be applied via ALTER TABLE.\n\n- `\"recreate\"` — lossless: create temp table, copy data, drop old, rename.\n- `\"drop\"` — lossy: drop table entirely and create from scratch.\n\nWithout this annotation, structural changes fail with an error requiring manual intervention
|
|
1786
|
+
description: annotationExample("Controls how the sync engine handles structural changes that cannot be applied via ALTER TABLE.\n\n- `\"recreate\"` — lossless: create temp table, copy data, drop old, rename.\n- `\"drop\"` — lossy: drop table entirely and create from scratch.\n\nWithout this annotation, structural changes fail with an error requiring manual intervention.", "@db.sync.method \"drop\"", "interface Logs { ... }"),
|
|
1849
1787
|
nodeType: ["interface"],
|
|
1850
1788
|
passedWhenReferred: false,
|
|
1851
1789
|
argument: {
|
|
@@ -1856,7 +1794,7 @@ const dbTableAnnotations = {
|
|
|
1856
1794
|
}
|
|
1857
1795
|
}) },
|
|
1858
1796
|
depth: { limit: new AnnotationSpec({
|
|
1859
|
-
description: "Security guard on nested-write payloads. `N` is a non-negative integer declaring the maximum depth a client may nest `@db.rel.from` children in insert, replace, or patch payloads. Writes deeper than `N` are rejected at the server boundary with HTTP 400 before any DB access.\n\n**Default when absent:** `0` — any nested-write payload is rejected. Authors opt in explicitly to `N >= 1` when they want the server to accept deep writes. This is a security / blast-radius control, not a performance knob.\n\n**Scope:** affects only write acceptance. Has no effect on `/meta` serialization, read/query paths, or wire shape — the meta endpoint always ships FK refs as the shallow `{ id, metadata }` shape regardless of this annotation
|
|
1797
|
+
description: annotationExample("Security guard on nested-write payloads. `N` is a non-negative integer declaring the maximum depth a client may nest `@db.rel.from` children in insert, replace, or patch payloads. Writes deeper than `N` are rejected at the server boundary with HTTP 400 before any DB access.\n\n**Default when absent:** `0` — any nested-write payload is rejected. Authors opt in explicitly to `N >= 1` when they want the server to accept deep writes. This is a security / blast-radius control, not a performance knob.\n\n**Scope:** affects only write acceptance. Has no effect on `/meta` serialization, read/query paths, or wire shape — the meta endpoint always ships FK refs as the shallow `{ id, metadata }` shape regardless of this annotation.", "@db.table \"authors\"", "@db.depth.limit 2", "export interface Author { ... }"),
|
|
1860
1798
|
nodeType: ["interface"],
|
|
1861
1799
|
passedWhenReferred: false,
|
|
1862
1800
|
multiple: false,
|
|
@@ -1910,19 +1848,21 @@ function collectUniqueIndexGroups(owner) {
|
|
|
1910
1848
|
return groups;
|
|
1911
1849
|
}
|
|
1912
1850
|
function tableCapability(capability) {
|
|
1913
|
-
const example = capability === "filterable" ?
|
|
1851
|
+
const example = capability === "filterable" ? [
|
|
1852
|
+
" @db.column.filterable",
|
|
1853
|
+
" email: string",
|
|
1854
|
+
" // other fields not filterable via the controller"
|
|
1855
|
+
] : [
|
|
1856
|
+
" @db.column.sortable",
|
|
1857
|
+
" createdAt: number.timestamp",
|
|
1858
|
+
" // other fields not sortable via the controller"
|
|
1859
|
+
];
|
|
1914
1860
|
const verb = capability === "filterable" ? "filter" : "sort";
|
|
1915
1861
|
return new AnnotationSpec({
|
|
1916
|
-
description: `Controls ${verb}-gating on the readable controller's \`/query\` and \`/pages\` endpoints.\n\n- **\`'auto'\`** (default when the annotation is absent) — every column the adapter can ${verb} is ${capability} (JSON / array columns are never sortable, and not filterable on SQL adapters).
|
|
1862
|
+
description: annotationExample(`Controls ${verb}-gating on the readable controller's \`/query\` and \`/pages\` endpoints.\n\n- **\`'auto'\`** (default when the annotation is absent) — every column the adapter can ${verb} is ${capability} (JSON / array columns are never sortable, and not filterable on SQL adapters).
|
|
1917
1863
|
- **\`'manual'\`** — only fields annotated \`@db.column.${capability}\` are ${capability}; all others are rejected with HTTP 400.
|
|
1918
1864
|
|
|
1919
|
-
\`/meta.fields[*].` + capability + "` advertises exactly what the gate accepts in either mode. The policy applies to " + (capability === "filterable" ? "filters only — `$groupBy`, `$having` keys and aggregate fields use the adapter's physical capability.\n\n" : "`$sort` only.\n\n") + `Writing the annotation explicitly as \`@db.table.${capability} 'auto'\` has the same runtime effect as omitting it; use it to document intent.
|
|
1920
|
-
|
|
1921
|
-
**Example:**
|
|
1922
|
-
\`\`\`atscript
|
|
1923
|
-
@db.table "users"
|
|
1924
|
-
@db.table.${capability} "manual"\nexport interface User {
|
|
1925
|
-
` + example + "}\n```\n",
|
|
1865
|
+
\`/meta.fields[*].` + capability + "` advertises exactly what the gate accepts in either mode. The policy applies to " + (capability === "filterable" ? "filters only — `$groupBy`, `$having` keys and aggregate fields use the adapter's physical capability.\n\n" : "`$sort` only.\n\n") + `Writing the annotation explicitly as \`@db.table.${capability} 'auto'\` has the same runtime effect as omitting it; use it to document intent.`, "@db.table \"users\"", `@db.table.${capability} "manual"`, "export interface User {", ...example, "}"),
|
|
1926
1866
|
nodeType: ["interface"],
|
|
1927
1867
|
passedWhenReferred: false,
|
|
1928
1868
|
multiple: false,
|
|
@@ -1949,7 +1889,7 @@ function tableCapability(capability) {
|
|
|
1949
1889
|
const UNIT_HOST_TYPES = ["decimal", "number"];
|
|
1950
1890
|
const dbUnitAnnotations = { unit: {
|
|
1951
1891
|
$self: new AnnotationSpec({
|
|
1952
|
-
description: "Hard-coded **unit of measure** for this quantity field. Use when a table stores all values for this field in a single, fixed unit.\n\nFor per-row units, use `@db.unit.ref 'fieldName'` instead.\n\nValid on `decimal` and `number` fields. Unlike `@db.amount.currency`, the unit code is free-form (`'kg'`, `'rpm'`, `'qps'`, …) — no ISO-style validation.\n\nAggregations on a unit-tagged field do not need grouping when the unit is a fixed literal — the constraint is satisfied schema-wide
|
|
1892
|
+
description: annotationExample("Hard-coded **unit of measure** for this quantity field. Use when a table stores all values for this field in a single, fixed unit.\n\nFor per-row units, use `@db.unit.ref 'fieldName'` instead.\n\nValid on `decimal` and `number` fields. Unlike `@db.amount.currency`, the unit code is free-form (`'kg'`, `'rpm'`, `'qps'`, …) — no ISO-style validation.\n\nAggregations on a unit-tagged field do not need grouping when the unit is a fixed literal — the constraint is satisfied schema-wide.", "@db.unit 'kg'", "weight: decimal"),
|
|
1953
1893
|
nodeType: ["prop"],
|
|
1954
1894
|
multiple: false,
|
|
1955
1895
|
argument: {
|
|
@@ -1963,12 +1903,12 @@ const dbUnitAnnotations = { unit: {
|
|
|
1963
1903
|
errors.push(...validateExclusiveWith(token, "@db.unit", [{
|
|
1964
1904
|
key: "db.unit.ref",
|
|
1965
1905
|
displayName: "@db.unit.ref"
|
|
1966
|
-
}]));
|
|
1906
|
+
}], doc));
|
|
1967
1907
|
return errors;
|
|
1968
1908
|
}
|
|
1969
1909
|
}),
|
|
1970
1910
|
ref: new AnnotationSpec({
|
|
1971
|
-
description: "Binds this quantity to a **sibling field** that holds its unit. Use when different rows may carry different units (e.g. mixed kg/lb measurements).\n\nThe referenced field must be a `string`.\n\nValid on `decimal` and `number` fields. Aggregating this field forces the referenced unit field into `$groupBy` at runtime — summing rows with different units is rejected
|
|
1911
|
+
description: annotationExample("Binds this quantity to a **sibling field** that holds its unit. Use when different rows may carry different units (e.g. mixed kg/lb measurements).\n\nThe referenced field must be a `string`.\n\nValid on `decimal` and `number` fields. Aggregating this field forces the referenced unit field into `$groupBy` at runtime — summing rows with different units is rejected.", "unit: string", "@db.unit.ref 'unit'", "weight: decimal"),
|
|
1972
1912
|
nodeType: ["prop"],
|
|
1973
1913
|
passedWhenReferred: false,
|
|
1974
1914
|
multiple: false,
|
|
@@ -1986,7 +1926,7 @@ const dbUnitAnnotations = { unit: {
|
|
|
1986
1926
|
}, {
|
|
1987
1927
|
key: "db.amount.currency.ref",
|
|
1988
1928
|
displayName: "@db.amount.currency.ref"
|
|
1989
|
-
}]));
|
|
1929
|
+
}], doc));
|
|
1990
1930
|
errors.push(...validateSiblingStringField(token, args, doc, "@db.unit.ref"));
|
|
1991
1931
|
return errors;
|
|
1992
1932
|
}
|
|
@@ -2051,7 +1991,7 @@ function orderKeyProblem(doc, target, path) {
|
|
|
2051
1991
|
}
|
|
2052
1992
|
const dbViewAnnotations = { view: {
|
|
2053
1993
|
$self: new AnnotationSpec({
|
|
2054
|
-
description: "Marks an interface as a **database view**. Optionally takes a view name argument
|
|
1994
|
+
description: annotationExample("Marks an interface as a **database view**. Optionally takes a view name argument.", "@db.view \"active_premium_users\"", "@db.view.for User", "export interface ActivePremiumUser { ... }"),
|
|
2055
1995
|
nodeType: ["interface"],
|
|
2056
1996
|
passedWhenReferred: false,
|
|
2057
1997
|
argument: {
|
|
@@ -2071,7 +2011,7 @@ const dbViewAnnotations = { view: {
|
|
|
2071
2011
|
}
|
|
2072
2012
|
}),
|
|
2073
2013
|
for: new AnnotationSpec({
|
|
2074
|
-
description: "Specifies the entry/primary source for a computed view — a `@db.table`, or another `@db.view` (views over views, since 0.1.141). Required for views that map fields via chain refs. A `@db.alias` type cannot be the entry
|
|
2014
|
+
description: annotationExample("Specifies the entry/primary source for a computed view — a `@db.table`, or another `@db.view` (views over views, since 0.1.141). Required for views that map fields via chain refs. A `@db.alias` type cannot be the entry.", "@db.view.for Order", "@db.view.filter `Order.status = 'active'`", "export interface ActiveOrderDetails { ... }"),
|
|
2075
2015
|
nodeType: ["interface"],
|
|
2076
2016
|
passedWhenReferred: false,
|
|
2077
2017
|
argument: {
|
|
@@ -2100,7 +2040,7 @@ const dbViewAnnotations = { view: {
|
|
|
2100
2040
|
}
|
|
2101
2041
|
}),
|
|
2102
2042
|
joins: new AnnotationSpec({
|
|
2103
|
-
description: "Declares an explicit join for a view. Joins are INNER by default — pass `'left'` as the third argument to keep entry rows without a match (fields read from a left-joined table must be optional). A join condition may reference the entry table and joins declared before it (chained joins). The target is a `@db.table`, a `@db.view` (since 0.1.141), or a `@db.alias` type — the way to join one table twice or to self-join the entry table: every scope name (entry + joins) must be unique
|
|
2043
|
+
description: annotationExample("Declares an explicit join for a view. Joins are INNER by default — pass `'left'` as the third argument to keep entry rows without a match (fields read from a left-joined table must be optional). A join condition may reference the entry table and joins declared before it (chained joins). The target is a `@db.table`, a `@db.view` (since 0.1.141), or a `@db.alias` type — the way to join one table twice or to self-join the entry table: every scope name (entry + joins) must be unique.", "@db.view.for Order", "@db.view.joins Customer, `Customer.id = Order.customerId`", "@db.view.joins Region, `Region.id = Customer.regionId`, 'left'", "export interface OrderRegion { ... }", "```", "", "**First-row join** (since 0.1.147): a 4th argument — an ordering of the target's fields (`` `raisedAt, id` ``, `` `severity desc` ``) — joins only the FIRST matching target row by that ordering (the target's primary key is appended as the final tie-break), so every field read through the join comes from that one row. NULL sorts first in `asc`.", "```atscript", "@db.view.joins OldestOpenIssue, `OldestOpenIssue.ticketId = Ticket.id and OldestOpenIssue.status = 'open'`, 'left', `raisedAt`"),
|
|
2104
2044
|
nodeType: ["interface"],
|
|
2105
2045
|
passedWhenReferred: false,
|
|
2106
2046
|
multiple: true,
|
|
@@ -2185,7 +2125,7 @@ const dbViewAnnotations = { view: {
|
|
|
2185
2125
|
}
|
|
2186
2126
|
}),
|
|
2187
2127
|
filter: new AnnotationSpec({
|
|
2188
|
-
description: "WHERE clause for a view, filtering which rows are included
|
|
2128
|
+
description: annotationExample("WHERE clause for a view, filtering which rows are included.", "@db.view.for User", "@db.view.filter `User.status = 'active' and User.age >= 18`", "export interface ActiveUser { ... }"),
|
|
2189
2129
|
nodeType: ["interface"],
|
|
2190
2130
|
passedWhenReferred: false,
|
|
2191
2131
|
argument: {
|
|
@@ -2220,7 +2160,7 @@ const dbViewAnnotations = { view: {
|
|
|
2220
2160
|
}
|
|
2221
2161
|
}),
|
|
2222
2162
|
materialized: new AnnotationSpec({
|
|
2223
|
-
description: "Marks a view as materialized (precomputed, stored on disk). Supported by PostgreSQL, CockroachDB, Oracle, SQL Server (indexed views), Snowflake. MongoDB supports on-demand materialized views via $merge/$out. Not applicable to MySQL or SQLite
|
|
2163
|
+
description: annotationExample("Marks a view as materialized (precomputed, stored on disk). Supported by PostgreSQL, CockroachDB, Oracle, SQL Server (indexed views), Snowflake. MongoDB supports on-demand materialized views via $merge/$out. Not applicable to MySQL or SQLite.", "@db.view.materialized", "@db.view.for User", "@db.view.filter `User.status = 'active'`", "export interface ActiveUsers { ... }"),
|
|
2224
2164
|
nodeType: ["interface"],
|
|
2225
2165
|
passedWhenReferred: false,
|
|
2226
2166
|
validate(token, _args, _doc) {
|
|
@@ -2235,7 +2175,7 @@ const dbViewAnnotations = { view: {
|
|
|
2235
2175
|
}
|
|
2236
2176
|
}),
|
|
2237
2177
|
renamed: new AnnotationSpec({
|
|
2238
|
-
description: "Specifies the previous view name for view rename migration. The sync engine drops the old view and creates the new one
|
|
2178
|
+
description: annotationExample("Specifies the previous view name for view rename migration. The sync engine drops the old view and creates the new one.", "@db.view \"active_premium_users\"", "@db.view.renamed \"active_users\"", "@db.view.for User", "export interface ActivePremiumUser { ... }"),
|
|
2239
2179
|
nodeType: ["interface"],
|
|
2240
2180
|
passedWhenReferred: false,
|
|
2241
2181
|
argument: {
|
|
@@ -2255,7 +2195,7 @@ const dbViewAnnotations = { view: {
|
|
|
2255
2195
|
}
|
|
2256
2196
|
}),
|
|
2257
2197
|
having: new AnnotationSpec({
|
|
2258
|
-
description: "Post-aggregation filter (HAVING clause) for analytical views. References the view's own fields, unqualified (`totalRevenue > 100`) — aggregate fields by their alias, plain fields as GROUP BY dimensions; source-table refs are not in scope
|
|
2198
|
+
description: annotationExample("Post-aggregation filter (HAVING clause) for analytical views. References the view's own fields, unqualified (`totalRevenue > 100`) — aggregate fields by their alias, plain fields as GROUP BY dimensions; source-table refs are not in scope.", "@db.view", "@db.view.for Order", "@db.view.having `totalRevenue > 100`", "export interface TopCategories { ... }"),
|
|
2259
2199
|
nodeType: ["interface"],
|
|
2260
2200
|
passedWhenReferred: false,
|
|
2261
2201
|
argument: {
|
|
@@ -2323,18 +2263,18 @@ const dbPlugin = (options) => ({
|
|
|
2323
2263
|
kind: "array",
|
|
2324
2264
|
of: "number"
|
|
2325
2265
|
},
|
|
2326
|
-
documentation: "Represents a **vector embedding** (array of numbers) for **similarity search**.\n\n- Equivalent to `number[]` but explicitly marks the field as a vector embedding.\n- Each adapter maps this to its native vector type:\n - **MongoDB** → BSON array\n - **MySQL 9+** → `VECTOR(N)`\n - **PostgreSQL** → pgvector `vector(N)`\n - **SQLite** → JSON
|
|
2266
|
+
documentation: annotationExample("Represents a **vector embedding** (array of numbers) for **similarity search**.\n\n- Equivalent to `number[]` but explicitly marks the field as a vector embedding.\n- Each adapter maps this to its native vector type:\n - **MongoDB** → BSON array\n - **MySQL 9+** → `VECTOR(N)`\n - **PostgreSQL** → pgvector `vector(N)`\n - **SQLite** → JSON", "@db.search.vector 1536, \"cosine\"", "embedding: db.vector")
|
|
2327
2267
|
},
|
|
2328
2268
|
geoPoint: {
|
|
2329
2269
|
type: {
|
|
2330
2270
|
kind: "array",
|
|
2331
2271
|
of: "number"
|
|
2332
2272
|
},
|
|
2333
|
-
documentation: "Represents a **geographic point** as a `[longitude, latitude]` tuple (GeoJSON coordinate order).\n\n- Equivalent to `number[]` of length 2, but explicitly marks the field as a geo point.\n- **Coordinate order is GeoJSON order: longitude first.**\n- Each adapter maps this to its native storage:\n - **MongoDB** → GeoJSON `{ type: 'Point', coordinates: [lng, lat] }`\n - **PostgreSQL** → PostGIS `geography(Point,4326)` (JSONB without PostGIS)\n - **MySQL** → `POINT SRID 4326`\n - **SQLite** → JSON `TEXT` (haversine-based search)
|
|
2273
|
+
documentation: annotationExample("Represents a **geographic point** as a `[longitude, latitude]` tuple (GeoJSON coordinate order).\n\n- Equivalent to `number[]` of length 2, but explicitly marks the field as a geo point.\n- **Coordinate order is GeoJSON order: longitude first.**\n- Each adapter maps this to its native storage:\n - **MongoDB** → GeoJSON `{ type: 'Point', coordinates: [lng, lat] }`\n - **PostgreSQL** → PostGIS `geography(Point,4326)` (JSONB without PostGIS)\n - **MySQL** → `POINT SRID 4326`\n - **SQLite** → JSON `TEXT` (haversine-based search)", "@db.index.geo", "geo: db.geoPoint")
|
|
2334
2274
|
},
|
|
2335
2275
|
currencyCode: {
|
|
2336
2276
|
type: "string",
|
|
2337
|
-
documentation: "Represents a **currency code** — typically ISO 4217 (`'USD'`, `'EUR'`, `'JPY'`) but accepts any uppercase alphanumeric code 2–10 chars long, so crypto and custom codes (`'BTC'`, `'USDC'`, `'POINTS'`) fit too.\n\nPair with `@db.amount.currency.ref 'fieldName'` on a `decimal` field to bind the amount to its row-level currency. The validator checks that the ref target resolves to this type (or a plain `string`)
|
|
2277
|
+
documentation: annotationExample("Represents a **currency code** — typically ISO 4217 (`'USD'`, `'EUR'`, `'JPY'`) but accepts any uppercase alphanumeric code 2–10 chars long, so crypto and custom codes (`'BTC'`, `'USDC'`, `'POINTS'`) fit too.\n\nPair with `@db.amount.currency.ref 'fieldName'` on a `decimal` field to bind the amount to its row-level currency. The validator checks that the ref target resolves to this type (or a plain `string`).", "currency: db.currencyCode", "@db.amount.currency.ref 'currency'", "amount: decimal"),
|
|
2338
2278
|
annotations: { "expect.pattern": [{
|
|
2339
2279
|
pattern: "^[A-Z0-9]{2,10}$",
|
|
2340
2280
|
message: "Invalid currency code (expected 2–10 uppercase letters or digits)"
|