@atscript/db 0.1.38
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/LICENSE +21 -0
- package/README.md +343 -0
- package/dist/agg-BJFJ3dFQ.mjs +8 -0
- package/dist/agg-DnUWAOK8.cjs +14 -0
- package/dist/agg.cjs +3 -0
- package/dist/agg.d.ts +13 -0
- package/dist/agg.mjs +3 -0
- package/dist/chunk-CrpGerW8.cjs +31 -0
- package/dist/control_as-BFPERAF_.cjs +28 -0
- package/dist/control_as-bjmwe24C.mjs +26 -0
- package/dist/index.cjs +2887 -0
- package/dist/index.d.ts +1706 -0
- package/dist/index.mjs +2846 -0
- package/dist/logger-B7oxCfLQ.mjs +12 -0
- package/dist/logger-Dt2v_-wb.cjs +18 -0
- package/dist/nested-writer-BkqL7cp3.cjs +667 -0
- package/dist/nested-writer-NEN51mnR.mjs +576 -0
- package/dist/plugin.cjs +993 -0
- package/dist/plugin.d.ts +5 -0
- package/dist/plugin.mjs +989 -0
- package/dist/rel.cjs +20 -0
- package/dist/rel.d.ts +1305 -0
- package/dist/rel.mjs +5 -0
- package/dist/relation-helpers-DyBIlQnB.mjs +29 -0
- package/dist/relation-helpers-guFL_oRf.cjs +47 -0
- package/dist/relation-loader-CpnDRf9k.cjs +415 -0
- package/dist/relation-loader-D4mTw6yH.cjs +4 -0
- package/dist/relation-loader-Dv7qXYq7.mjs +409 -0
- package/dist/relation-loader-Ggy1ujwR.mjs +4 -0
- package/dist/shared.cjs +13 -0
- package/dist/shared.d.ts +70 -0
- package/dist/shared.mjs +3 -0
- package/dist/sync.cjs +1205 -0
- package/dist/sync.d.ts +1878 -0
- package/dist/sync.mjs +1186 -0
- package/dist/validation-utils-DEoCMmEb.cjs +304 -0
- package/dist/validation-utils-DhR_mtKa.mjs +237 -0
- package/package.json +81 -0
package/dist/plugin.mjs
ADDED
|
@@ -0,0 +1,989 @@
|
|
|
1
|
+
import { findFKFieldsPointingTo, getAnnotationAlias, getDbTableOwner, getNavTargetTypeName, getParentStruct, getParentTypeName, hasAnyViewAnnotation, refActionAnnotation, validateFieldBaseType, validateQueryScope, validateRefArgument } from "./validation-utils-DhR_mtKa.mjs";
|
|
2
|
+
import { AnnotationSpec, isArray, isInterface, isPrimitive, isRef, isStructure } from "@atscript/core";
|
|
3
|
+
|
|
4
|
+
//#region packages/db/src/plugin/annotations/agg.ts
|
|
5
|
+
const dbAggAnnotations = { agg: {
|
|
6
|
+
sum: new AnnotationSpec({
|
|
7
|
+
description: "Declares a view field as SUM of a source column.",
|
|
8
|
+
nodeType: ["prop"],
|
|
9
|
+
argument: {
|
|
10
|
+
name: "field",
|
|
11
|
+
type: "string",
|
|
12
|
+
description: "Source column name to sum."
|
|
13
|
+
},
|
|
14
|
+
validate(token, _args, doc) {
|
|
15
|
+
return validateFieldBaseType(token, doc, "@db.agg.sum", ["number", "decimal"]);
|
|
16
|
+
}
|
|
17
|
+
}),
|
|
18
|
+
avg: new AnnotationSpec({
|
|
19
|
+
description: "Declares a view field as AVG of a source column.",
|
|
20
|
+
nodeType: ["prop"],
|
|
21
|
+
argument: {
|
|
22
|
+
name: "field",
|
|
23
|
+
type: "string",
|
|
24
|
+
description: "Source column name to average."
|
|
25
|
+
},
|
|
26
|
+
validate(token, _args, doc) {
|
|
27
|
+
return validateFieldBaseType(token, doc, "@db.agg.avg", ["number", "decimal"]);
|
|
28
|
+
}
|
|
29
|
+
}),
|
|
30
|
+
count: new AnnotationSpec({
|
|
31
|
+
description: "Declares a view field as COUNT. Without argument: COUNT(*). With field name argument: COUNT(field) (non-null count).",
|
|
32
|
+
nodeType: ["prop"],
|
|
33
|
+
argument: {
|
|
34
|
+
name: "field",
|
|
35
|
+
type: "string",
|
|
36
|
+
optional: true,
|
|
37
|
+
description: "Source column name to count non-null values. Omit for COUNT(*)."
|
|
38
|
+
},
|
|
39
|
+
validate(token, _args, doc) {
|
|
40
|
+
return validateFieldBaseType(token, doc, "@db.agg.count", ["number"]);
|
|
41
|
+
}
|
|
42
|
+
}),
|
|
43
|
+
min: new AnnotationSpec({
|
|
44
|
+
description: "Declares a view field as MIN of a source column.",
|
|
45
|
+
nodeType: ["prop"],
|
|
46
|
+
argument: {
|
|
47
|
+
name: "field",
|
|
48
|
+
type: "string",
|
|
49
|
+
description: "Source column name."
|
|
50
|
+
}
|
|
51
|
+
}),
|
|
52
|
+
max: new AnnotationSpec({
|
|
53
|
+
description: "Declares a view field as MAX of a source column.",
|
|
54
|
+
nodeType: ["prop"],
|
|
55
|
+
argument: {
|
|
56
|
+
name: "field",
|
|
57
|
+
type: "string",
|
|
58
|
+
description: "Source column name."
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
} };
|
|
62
|
+
|
|
63
|
+
//#endregion
|
|
64
|
+
//#region packages/db/src/plugin/annotations/column.ts
|
|
65
|
+
const dbColumnAnnotations = {
|
|
66
|
+
patch: { strategy: new AnnotationSpec({
|
|
67
|
+
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).\n\n**Example:**\n```atscript\n@db.patch.strategy \"merge\"\nsettings: {\n notifications: boolean\n preferences: {\n theme: string\n }\n}\n```\n",
|
|
68
|
+
nodeType: ["prop"],
|
|
69
|
+
multiple: false,
|
|
70
|
+
argument: {
|
|
71
|
+
name: "strategy",
|
|
72
|
+
type: "string",
|
|
73
|
+
description: "The **patch strategy** for this field: `\"replace\"` (default) or `\"merge\"`.",
|
|
74
|
+
values: ["replace", "merge"]
|
|
75
|
+
},
|
|
76
|
+
validate(token, args, doc) {
|
|
77
|
+
const field = token.parentNode;
|
|
78
|
+
const errors = [];
|
|
79
|
+
const definition = field.getDefinition();
|
|
80
|
+
if (!definition) return errors;
|
|
81
|
+
let wrongType = false;
|
|
82
|
+
if (isRef(definition)) {
|
|
83
|
+
const def = doc.unwindType(definition.id, definition.chain)?.def;
|
|
84
|
+
if (!isStructure(def) && !isInterface(def) && !isArray(def)) wrongType = true;
|
|
85
|
+
} else if (!isStructure(definition) && !isInterface(definition) && !isArray(definition)) wrongType = true;
|
|
86
|
+
if (wrongType) errors.push({
|
|
87
|
+
message: `@db.patch.strategy requires a field of type object or array`,
|
|
88
|
+
severity: 1,
|
|
89
|
+
range: token.range
|
|
90
|
+
});
|
|
91
|
+
return errors;
|
|
92
|
+
}
|
|
93
|
+
}) },
|
|
94
|
+
column: {
|
|
95
|
+
$self: new AnnotationSpec({
|
|
96
|
+
description: "Overrides the physical column name in the database. For nested (flattened) fields, the parent prefix is still prepended automatically.\n\n**Example:**\n```atscript\n@db.column \"first_name\"\nfirstName: string\n// → physical column: first_name\n\n// Nested:\naddress: {\n @db.column \"zip_code\"\n zip: string\n}\n// → physical column: address__zip_code\n```\n",
|
|
97
|
+
nodeType: ["prop"],
|
|
98
|
+
argument: {
|
|
99
|
+
name: "name",
|
|
100
|
+
type: "string",
|
|
101
|
+
description: "The column/field name (without parent prefix for nested fields)."
|
|
102
|
+
}
|
|
103
|
+
}),
|
|
104
|
+
renamed: new AnnotationSpec({
|
|
105
|
+
description: "Specifies the previous local field name for column rename migration. The sync engine generates ALTER TABLE RENAME COLUMN instead of drop+add.\n\n**Example:**\n```atscript\n@db.column.renamed \"zip\"\npostalCode: string\n// Renames address__zip → address__postalCode\n```\n",
|
|
106
|
+
nodeType: ["prop"],
|
|
107
|
+
argument: {
|
|
108
|
+
name: "oldName",
|
|
109
|
+
type: "string",
|
|
110
|
+
description: "The old local field name (parent prefix is reconstructed automatically)."
|
|
111
|
+
}
|
|
112
|
+
}),
|
|
113
|
+
collate: new AnnotationSpec({
|
|
114
|
+
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.\n\n**Example:**\n```atscript\n@db.column.collate \"nocase\"\nusername: string\n```\n",
|
|
115
|
+
nodeType: ["prop"],
|
|
116
|
+
argument: {
|
|
117
|
+
name: "collation",
|
|
118
|
+
type: "string",
|
|
119
|
+
values: [
|
|
120
|
+
"binary",
|
|
121
|
+
"nocase",
|
|
122
|
+
"unicode"
|
|
123
|
+
],
|
|
124
|
+
description: "Portable collation mode: \"binary\", \"nocase\", or \"unicode\"."
|
|
125
|
+
},
|
|
126
|
+
validate(token, args, doc) {
|
|
127
|
+
return validateFieldBaseType(token, doc, "@db.column.collate", "string");
|
|
128
|
+
}
|
|
129
|
+
}),
|
|
130
|
+
precision: new AnnotationSpec({
|
|
131
|
+
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.\n\n**Example:**\n```atscript\n@db.column.precision 10, 2\nprice: decimal\n```\n",
|
|
132
|
+
nodeType: ["prop"],
|
|
133
|
+
argument: [{
|
|
134
|
+
name: "precision",
|
|
135
|
+
type: "number",
|
|
136
|
+
description: "Total number of significant digits."
|
|
137
|
+
}, {
|
|
138
|
+
name: "scale",
|
|
139
|
+
type: "number",
|
|
140
|
+
description: "Number of digits after the decimal point."
|
|
141
|
+
}],
|
|
142
|
+
validate(token, args, doc) {
|
|
143
|
+
return validateFieldBaseType(token, doc, "@db.column.precision", ["number", "decimal"]);
|
|
144
|
+
}
|
|
145
|
+
}),
|
|
146
|
+
dimension: new AnnotationSpec({
|
|
147
|
+
description: "Marks a field as a dimension — groupable in aggregate queries ($groupBy). Dimension fields automatically receive a database index during schema sync.",
|
|
148
|
+
nodeType: ["prop"]
|
|
149
|
+
}),
|
|
150
|
+
measure: new AnnotationSpec({
|
|
151
|
+
description: "Marks a field as a measure — aggregatable in aggregate queries (sum, avg, count, min, max). Only valid on numeric or decimal fields.",
|
|
152
|
+
nodeType: ["prop"],
|
|
153
|
+
validate(token, _args, doc) {
|
|
154
|
+
return validateFieldBaseType(token, doc, "@db.column.measure", ["number", "decimal"]);
|
|
155
|
+
}
|
|
156
|
+
})
|
|
157
|
+
},
|
|
158
|
+
default: {
|
|
159
|
+
$self: new AnnotationSpec({
|
|
160
|
+
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.\n\n**Example:**\n```atscript\n@db.default \"active\"\nstatus: string\n```\n",
|
|
161
|
+
nodeType: ["prop"],
|
|
162
|
+
argument: {
|
|
163
|
+
name: "value",
|
|
164
|
+
type: "string",
|
|
165
|
+
description: "Static default value. Strings used as-is; other types parsed via JSON.parse()."
|
|
166
|
+
}
|
|
167
|
+
}),
|
|
168
|
+
increment: new AnnotationSpec({
|
|
169
|
+
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).\n\n**Example:**\n```atscript\n@db.default.increment\nid: number.int\n\n// With optional start value:\n@db.default.increment 1000\nid: number.int\n```\n",
|
|
170
|
+
nodeType: ["prop"],
|
|
171
|
+
argument: {
|
|
172
|
+
optional: true,
|
|
173
|
+
name: "start",
|
|
174
|
+
type: "number",
|
|
175
|
+
description: "Starting value for the auto-increment sequence. Adapter-specific behavior; some adapters may ignore this."
|
|
176
|
+
},
|
|
177
|
+
validate(token, args, doc) {
|
|
178
|
+
return validateFieldBaseType(token, doc, "db.default.increment", "number");
|
|
179
|
+
}
|
|
180
|
+
}),
|
|
181
|
+
uuid: new AnnotationSpec({
|
|
182
|
+
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).\n\n**Example:**\n```atscript\n@db.default.uuid\nid: string.uuid\n```\n",
|
|
183
|
+
nodeType: ["prop"],
|
|
184
|
+
validate(token, args, doc) {
|
|
185
|
+
return validateFieldBaseType(token, doc, "db.default.uuid", "string");
|
|
186
|
+
}
|
|
187
|
+
}),
|
|
188
|
+
now: new AnnotationSpec({
|
|
189
|
+
description: "Current timestamp default. Each adapter maps this to its native mechanism (e.g., `DEFAULT CURRENT_TIMESTAMP` in MySQL, `DEFAULT now()` in PostgreSQL).\n\n**Example:**\n```atscript\n@db.default.now\ncreatedAt: number.timestamp\n```\n",
|
|
190
|
+
nodeType: ["prop"],
|
|
191
|
+
validate(token, args, doc) {
|
|
192
|
+
return validateFieldBaseType(token, doc, "db.default.now", ["number", "string"]);
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
},
|
|
196
|
+
json: new AnnotationSpec({
|
|
197
|
+
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.\n\n**Example:**\n```atscript\n@db.json\nmetadata: { key: string, value: string }\n```\n",
|
|
198
|
+
nodeType: ["prop"],
|
|
199
|
+
validate(token, _args, doc) {
|
|
200
|
+
const errors = [];
|
|
201
|
+
const field = token.parentNode;
|
|
202
|
+
const definition = field.getDefinition();
|
|
203
|
+
if (definition && isRef(definition)) {
|
|
204
|
+
const unwound = doc.unwindType(definition.id, definition.chain);
|
|
205
|
+
if (unwound && isPrimitive(unwound.def)) errors.push({
|
|
206
|
+
message: "@db.json on a primitive field has no effect — primitive fields are already stored as scalar columns",
|
|
207
|
+
severity: 2,
|
|
208
|
+
range: token.range
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
return errors;
|
|
212
|
+
}
|
|
213
|
+
}),
|
|
214
|
+
ignore: new AnnotationSpec({
|
|
215
|
+
description: "Excludes a field from the database schema. The field exists in the Atscript type but has no column in the DB.\n\n**Example:**\n```atscript\n@db.ignore\ndisplayName: string\n```\n",
|
|
216
|
+
nodeType: ["prop"],
|
|
217
|
+
validate(token, args, doc) {
|
|
218
|
+
const errors = [];
|
|
219
|
+
const field = token.parentNode;
|
|
220
|
+
if (field.countAnnotations("meta.id") > 0) errors.push({
|
|
221
|
+
message: `@db.ignore cannot coexist with @meta.id — a field cannot be both a primary key and excluded from the database`,
|
|
222
|
+
severity: 1,
|
|
223
|
+
range: token.range
|
|
224
|
+
});
|
|
225
|
+
return errors;
|
|
226
|
+
}
|
|
227
|
+
})
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
//#endregion
|
|
231
|
+
//#region packages/db/src/plugin/annotations/index-ann.ts
|
|
232
|
+
const dbIndexAnnotations = { index: {
|
|
233
|
+
plain: new AnnotationSpec({
|
|
234
|
+
description: "Standard (non-unique) index for query performance. Fields sharing the same index name form a composite index.\n\n**Example:**\n```atscript\n@db.index.plain \"idx_timeline\", \"desc\"\ncreatedAt: number.timestamp\n```\n",
|
|
235
|
+
nodeType: ["prop"],
|
|
236
|
+
multiple: true,
|
|
237
|
+
mergeStrategy: "append",
|
|
238
|
+
argument: [{
|
|
239
|
+
optional: true,
|
|
240
|
+
name: "name",
|
|
241
|
+
type: "string",
|
|
242
|
+
description: "Index name / composite group name."
|
|
243
|
+
}, {
|
|
244
|
+
optional: true,
|
|
245
|
+
name: "sort",
|
|
246
|
+
type: "string",
|
|
247
|
+
values: ["asc", "desc"],
|
|
248
|
+
description: "Sort direction. Defaults to \"asc\"."
|
|
249
|
+
}]
|
|
250
|
+
}),
|
|
251
|
+
unique: new AnnotationSpec({
|
|
252
|
+
description: "Unique index — ensures no two rows/documents have the same value(s). Fields sharing the same index name form a composite unique constraint.\n\n**Example:**\n```atscript\n@db.index.unique \"tenant_email\"\nemail: string.email\n```\n",
|
|
253
|
+
nodeType: ["prop"],
|
|
254
|
+
multiple: true,
|
|
255
|
+
mergeStrategy: "append",
|
|
256
|
+
argument: {
|
|
257
|
+
optional: true,
|
|
258
|
+
name: "name",
|
|
259
|
+
type: "string",
|
|
260
|
+
description: "Index name / composite group name."
|
|
261
|
+
}
|
|
262
|
+
}),
|
|
263
|
+
fulltext: new AnnotationSpec({
|
|
264
|
+
description: "Full-text search index. Fields sharing the same index name form a composite full-text index.\n\n**Example:**\n```atscript\n@db.index.fulltext \"ft_content\"\ntitle: string\n\n@db.index.fulltext \"ft_content\", 5\nbio: string\n```\n",
|
|
265
|
+
nodeType: ["prop"],
|
|
266
|
+
multiple: true,
|
|
267
|
+
mergeStrategy: "append",
|
|
268
|
+
argument: [{
|
|
269
|
+
optional: true,
|
|
270
|
+
name: "name",
|
|
271
|
+
type: "string",
|
|
272
|
+
description: "Index name / composite group name."
|
|
273
|
+
}, {
|
|
274
|
+
optional: true,
|
|
275
|
+
name: "weight",
|
|
276
|
+
type: "number",
|
|
277
|
+
description: "Field importance in search results (higher = more relevant). Defaults to `1`. Supported by databases with weighted fulltext (e.g., MongoDB, PostgreSQL)."
|
|
278
|
+
}]
|
|
279
|
+
})
|
|
280
|
+
} };
|
|
281
|
+
|
|
282
|
+
//#endregion
|
|
283
|
+
//#region packages/db/src/plugin/annotations/rel.ts
|
|
284
|
+
const dbRelAnnotations = { rel: {
|
|
285
|
+
FK: new AnnotationSpec({
|
|
286
|
+
description: "Declares a foreign key constraint on this field. The field must use a chain reference type (e.g., `User.id`) to specify the FK target.\n\n**Example:**\n```atscript\n@db.rel.FK\nauthorId: User.id\n\n// With alias (required when multiple FKs point to the same type)\n@db.rel.FK \"author\"\nauthorId: User.id\n```\n",
|
|
287
|
+
nodeType: ["prop"],
|
|
288
|
+
argument: {
|
|
289
|
+
optional: true,
|
|
290
|
+
name: "alias",
|
|
291
|
+
type: "string",
|
|
292
|
+
description: "Alias for pairing with @db.rel.to. Required when multiple FKs point to the same target type."
|
|
293
|
+
},
|
|
294
|
+
validate(token, args, doc) {
|
|
295
|
+
const errors = [];
|
|
296
|
+
const field = token.parentNode;
|
|
297
|
+
const alias = args[0]?.text;
|
|
298
|
+
const owner = getDbTableOwner(token);
|
|
299
|
+
if (!owner || owner.countAnnotations("db.table") === 0) errors.push({
|
|
300
|
+
message: "@db.rel.FK is only valid on fields of a @db.table interface",
|
|
301
|
+
severity: 1,
|
|
302
|
+
range: token.range
|
|
303
|
+
});
|
|
304
|
+
if (field.countAnnotations("db.rel.to") > 0 || field.countAnnotations("db.rel.from") > 0) errors.push({
|
|
305
|
+
message: "A field cannot be both a foreign key and a navigational property",
|
|
306
|
+
severity: 1,
|
|
307
|
+
range: token.range
|
|
308
|
+
});
|
|
309
|
+
const definition = field.getDefinition();
|
|
310
|
+
if (!definition || !isRef(definition) || !definition.hasChain) {
|
|
311
|
+
errors.push({
|
|
312
|
+
message: `@db.rel.FK requires a chain reference type (e.g. User.id), got scalar type`,
|
|
313
|
+
severity: 1,
|
|
314
|
+
range: token.range
|
|
315
|
+
});
|
|
316
|
+
return errors;
|
|
317
|
+
}
|
|
318
|
+
const ref = definition;
|
|
319
|
+
const refTypeName = ref.id;
|
|
320
|
+
const chainFields = ref.chain.map((c) => c.text);
|
|
321
|
+
const targetUnwound = doc.unwindType(refTypeName);
|
|
322
|
+
if (targetUnwound) {
|
|
323
|
+
const targetDef = targetUnwound.def;
|
|
324
|
+
if (isInterface(targetDef) || isStructure(targetDef)) {
|
|
325
|
+
const struct = isInterface(targetDef) ? targetDef.getDefinition() : targetDef;
|
|
326
|
+
if (struct && isStructure(struct) && chainFields.length > 0) {
|
|
327
|
+
const targetProp = struct.props.get(chainFields[0]);
|
|
328
|
+
if (targetProp) {
|
|
329
|
+
if (targetProp.countAnnotations("meta.id") === 0 && targetProp.countAnnotations("db.index.unique") === 0) errors.push({
|
|
330
|
+
message: `@db.rel.FK target '${refTypeName}.${chainFields.join(".")}' is not a primary key (@meta.id) or unique (@db.index.unique) field`,
|
|
331
|
+
severity: 1,
|
|
332
|
+
range: token.range
|
|
333
|
+
});
|
|
334
|
+
const propDef = targetProp.getDefinition();
|
|
335
|
+
if (propDef && isRef(propDef)) {
|
|
336
|
+
const propUnwound = targetUnwound.doc.unwindType(propDef.id, propDef.chain);
|
|
337
|
+
if (propUnwound && !isPrimitive(propUnwound.def)) errors.push({
|
|
338
|
+
message: `Foreign key field must resolve to a scalar type (number, string, etc.), got '${propDef.id}'`,
|
|
339
|
+
severity: 1,
|
|
340
|
+
range: token.range
|
|
341
|
+
});
|
|
342
|
+
} else if (propDef && !isPrimitive(propDef)) errors.push({
|
|
343
|
+
message: `Foreign key field must resolve to a scalar type (number, string, etc.)`,
|
|
344
|
+
severity: 1,
|
|
345
|
+
range: token.range
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (!alias) {
|
|
352
|
+
const struct = getParentStruct(token);
|
|
353
|
+
if (struct) {
|
|
354
|
+
let sameTargetCount = 0;
|
|
355
|
+
for (const [, prop] of struct.props) {
|
|
356
|
+
if (prop.countAnnotations("db.rel.FK") === 0) continue;
|
|
357
|
+
const def = prop.getDefinition();
|
|
358
|
+
if (!def || !isRef(def)) continue;
|
|
359
|
+
const r = def;
|
|
360
|
+
if (!r.hasChain) continue;
|
|
361
|
+
if (r.id === refTypeName) {
|
|
362
|
+
if (!getAnnotationAlias(prop, "db.rel.FK")) sameTargetCount++;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (sameTargetCount > 1) errors.push({
|
|
366
|
+
message: `Multiple @db.rel.FK fields resolve to type '${refTypeName}' — add alias to disambiguate`,
|
|
367
|
+
severity: 1,
|
|
368
|
+
range: token.range
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return errors;
|
|
373
|
+
}
|
|
374
|
+
}),
|
|
375
|
+
to: new AnnotationSpec({
|
|
376
|
+
description: "Forward navigational property — the FK is on **this** interface. The compiler resolves the matching @db.rel.FK by target type or alias.\n\n**Example:**\n```atscript\n@db.rel.to\nauthor?: User\n\n// With alias\n@db.rel.to \"author\"\nauthor?: User\n```\n",
|
|
377
|
+
nodeType: ["prop"],
|
|
378
|
+
argument: {
|
|
379
|
+
optional: true,
|
|
380
|
+
name: "alias",
|
|
381
|
+
type: "string",
|
|
382
|
+
description: "Match a local @db.rel.FK by alias."
|
|
383
|
+
},
|
|
384
|
+
validate(token, args, doc) {
|
|
385
|
+
const errors = [];
|
|
386
|
+
const field = token.parentNode;
|
|
387
|
+
const alias = args[0]?.text;
|
|
388
|
+
const owner = getDbTableOwner(token);
|
|
389
|
+
if (!owner || owner.countAnnotations("db.table") === 0) errors.push({
|
|
390
|
+
message: "@db.rel.to is only valid on fields of a @db.table interface",
|
|
391
|
+
severity: 1,
|
|
392
|
+
range: token.range
|
|
393
|
+
});
|
|
394
|
+
if (field.countAnnotations("db.rel.FK") > 0) errors.push({
|
|
395
|
+
message: "A field cannot be both a foreign key and a navigational property",
|
|
396
|
+
severity: 1,
|
|
397
|
+
range: token.range
|
|
398
|
+
});
|
|
399
|
+
const targetTypeName = getNavTargetTypeName(field);
|
|
400
|
+
if (!targetTypeName) return errors;
|
|
401
|
+
const unwound = doc.unwindType(targetTypeName);
|
|
402
|
+
if (unwound) {
|
|
403
|
+
const targetDef = unwound.def;
|
|
404
|
+
const targetNode = isInterface(targetDef) ? targetDef : undefined;
|
|
405
|
+
if (!targetNode || targetNode.countAnnotations("db.table") === 0) errors.push({
|
|
406
|
+
message: `@db.rel.to target '${targetTypeName}' is not a @db.table entity`,
|
|
407
|
+
severity: 1,
|
|
408
|
+
range: token.range
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
const fieldDef = field.getDefinition();
|
|
412
|
+
if (fieldDef && fieldDef.entity === "group" && fieldDef.op === "|") errors.push({
|
|
413
|
+
message: "@db.rel.to does not support union types — use separate relations",
|
|
414
|
+
severity: 1,
|
|
415
|
+
range: token.range
|
|
416
|
+
});
|
|
417
|
+
const struct = getParentStruct(token);
|
|
418
|
+
if (struct) {
|
|
419
|
+
const fieldName = field.id;
|
|
420
|
+
for (const [name, prop] of struct.props) {
|
|
421
|
+
if (name === fieldName) continue;
|
|
422
|
+
if (prop.countAnnotations("db.rel.to") === 0) continue;
|
|
423
|
+
const propAlias = getAnnotationAlias(prop, "db.rel.to");
|
|
424
|
+
if ((alias || undefined) === (propAlias || undefined)) {
|
|
425
|
+
const otherTarget = getNavTargetTypeName(prop);
|
|
426
|
+
if (otherTarget === targetTypeName) {
|
|
427
|
+
errors.push({
|
|
428
|
+
message: `Duplicate @db.rel.to '${alias || targetTypeName}' — only one forward navigational property per alias`,
|
|
429
|
+
severity: 1,
|
|
430
|
+
range: token.range
|
|
431
|
+
});
|
|
432
|
+
break;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
if (alias) {
|
|
437
|
+
const matches = findFKFieldsPointingTo(doc, struct, targetTypeName, alias);
|
|
438
|
+
if (matches.length === 0) errors.push({
|
|
439
|
+
message: `No @db.rel.FK '${alias}' found on this interface`,
|
|
440
|
+
severity: 1,
|
|
441
|
+
range: token.range
|
|
442
|
+
});
|
|
443
|
+
} else {
|
|
444
|
+
const matches = findFKFieldsPointingTo(doc, struct, targetTypeName);
|
|
445
|
+
if (matches.length === 0) errors.push({
|
|
446
|
+
message: `No @db.rel.FK on this interface points to '${targetTypeName}' — did you mean @db.rel.from?`,
|
|
447
|
+
severity: 1,
|
|
448
|
+
range: token.range
|
|
449
|
+
});
|
|
450
|
+
else if (matches.length > 1) errors.push({
|
|
451
|
+
message: `Multiple @db.rel.FK fields point to '${targetTypeName}' — add alias to disambiguate`,
|
|
452
|
+
severity: 1,
|
|
453
|
+
range: token.range
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return errors;
|
|
458
|
+
}
|
|
459
|
+
}),
|
|
460
|
+
from: new AnnotationSpec({
|
|
461
|
+
description: "Inverse navigational property — the FK is on the **target** interface, pointing back to this one.\n\n**Example:**\n```atscript\n@db.rel.from\nposts: Post[]\n\n// With alias\n@db.rel.from \"original\"\ncomments: Comment[]\n```\n",
|
|
462
|
+
nodeType: ["prop"],
|
|
463
|
+
argument: {
|
|
464
|
+
optional: true,
|
|
465
|
+
name: "alias",
|
|
466
|
+
type: "string",
|
|
467
|
+
description: "Match a @db.rel.FK on the target interface by alias."
|
|
468
|
+
},
|
|
469
|
+
validate(token, args, doc) {
|
|
470
|
+
const errors = [];
|
|
471
|
+
const field = token.parentNode;
|
|
472
|
+
const alias = args[0]?.text;
|
|
473
|
+
const owner = getDbTableOwner(token);
|
|
474
|
+
if (!owner || owner.countAnnotations("db.table") === 0) errors.push({
|
|
475
|
+
message: "@db.rel.from is only valid on fields of a @db.table interface",
|
|
476
|
+
severity: 1,
|
|
477
|
+
range: token.range
|
|
478
|
+
});
|
|
479
|
+
if (field.countAnnotations("db.rel.FK") > 0) errors.push({
|
|
480
|
+
message: "A field cannot be both a foreign key and a navigational property",
|
|
481
|
+
severity: 1,
|
|
482
|
+
range: token.range
|
|
483
|
+
});
|
|
484
|
+
const targetTypeName = getNavTargetTypeName(field);
|
|
485
|
+
if (!targetTypeName) return errors;
|
|
486
|
+
const unwound = doc.unwindType(targetTypeName);
|
|
487
|
+
if (!unwound) return errors;
|
|
488
|
+
const targetDef = unwound.def;
|
|
489
|
+
const targetDoc = unwound.doc;
|
|
490
|
+
if (!isInterface(targetDef) || targetDef.countAnnotations("db.table") === 0) {
|
|
491
|
+
errors.push({
|
|
492
|
+
message: `@db.rel.from target '${targetTypeName}' is not a @db.table entity`,
|
|
493
|
+
severity: 1,
|
|
494
|
+
range: token.range
|
|
495
|
+
});
|
|
496
|
+
return errors;
|
|
497
|
+
}
|
|
498
|
+
const struct = getParentStruct(token);
|
|
499
|
+
if (struct) {
|
|
500
|
+
const fieldName = field.id;
|
|
501
|
+
for (const [name, prop] of struct.props) {
|
|
502
|
+
if (name === fieldName) continue;
|
|
503
|
+
if (prop.countAnnotations("db.rel.from") === 0) continue;
|
|
504
|
+
const propAlias = getAnnotationAlias(prop, "db.rel.from");
|
|
505
|
+
if ((alias || undefined) === (propAlias || undefined)) {
|
|
506
|
+
const otherTarget = getNavTargetTypeName(prop);
|
|
507
|
+
if (otherTarget === targetTypeName) {
|
|
508
|
+
errors.push({
|
|
509
|
+
message: `Duplicate @db.rel.from '${alias || targetTypeName}' — only one inverse navigational property per alias`,
|
|
510
|
+
severity: 1,
|
|
511
|
+
range: token.range
|
|
512
|
+
});
|
|
513
|
+
break;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
const thisTypeName = getParentTypeName(token);
|
|
519
|
+
if (!thisTypeName) return errors;
|
|
520
|
+
const matches = findFKFieldsPointingTo(targetDoc, targetDef, thisTypeName, alias);
|
|
521
|
+
if (alias) {
|
|
522
|
+
if (matches.length === 0) errors.push({
|
|
523
|
+
message: `No @db.rel.FK '${alias}' found on '${targetTypeName}'`,
|
|
524
|
+
severity: 1,
|
|
525
|
+
range: token.range
|
|
526
|
+
});
|
|
527
|
+
} else if (matches.length === 0) errors.push({
|
|
528
|
+
message: `No @db.rel.FK on '${targetTypeName}' points to '${thisTypeName}'`,
|
|
529
|
+
severity: 1,
|
|
530
|
+
range: token.range
|
|
531
|
+
});
|
|
532
|
+
else if (matches.length > 1) errors.push({
|
|
533
|
+
message: `'${targetTypeName}' has multiple @db.rel.FK fields pointing to '${thisTypeName}' — add alias`,
|
|
534
|
+
severity: 1,
|
|
535
|
+
range: token.range
|
|
536
|
+
});
|
|
537
|
+
const fieldDef = field.getDefinition();
|
|
538
|
+
if (!isArray(fieldDef) && matches.length === 1) {
|
|
539
|
+
const fkProp = matches[0].prop;
|
|
540
|
+
if (fkProp.countAnnotations("db.index.unique") === 0) errors.push({
|
|
541
|
+
message: `@db.rel.from '${field.id}' has singular type '${targetTypeName}' (1:1) but the FK on '${targetTypeName}' is not @db.index.unique — did you mean '${targetTypeName}[]' (1:N)?`,
|
|
542
|
+
severity: 2,
|
|
543
|
+
range: token.range
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
return errors;
|
|
547
|
+
}
|
|
548
|
+
}),
|
|
549
|
+
onDelete: refActionAnnotation("onDelete"),
|
|
550
|
+
onUpdate: refActionAnnotation("onUpdate"),
|
|
551
|
+
via: new AnnotationSpec({
|
|
552
|
+
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.\n\n**Example:**\n```atscript\n@db.rel.via PostTag\ntags: Tag[]\n```\n",
|
|
553
|
+
nodeType: ["prop"],
|
|
554
|
+
argument: {
|
|
555
|
+
name: "junction",
|
|
556
|
+
type: "ref",
|
|
557
|
+
description: "The junction table type (must have @db.table and @db.rel.FK fields pointing to both sides)."
|
|
558
|
+
},
|
|
559
|
+
validate(token, args, doc) {
|
|
560
|
+
const errors = [];
|
|
561
|
+
const field = token.parentNode;
|
|
562
|
+
if (field.countAnnotations("db.rel.to") > 0 || field.countAnnotations("db.rel.from") > 0) errors.push({
|
|
563
|
+
message: "@db.rel.via is self-sufficient — cannot be combined with @db.rel.to or @db.rel.from",
|
|
564
|
+
severity: 1,
|
|
565
|
+
range: token.range
|
|
566
|
+
});
|
|
567
|
+
const definition = field.getDefinition();
|
|
568
|
+
if (!isArray(definition)) errors.push({
|
|
569
|
+
message: "@db.rel.via requires an array type (e.g. Tag[])",
|
|
570
|
+
severity: 1,
|
|
571
|
+
range: token.range
|
|
572
|
+
});
|
|
573
|
+
if (!args[0]) return errors;
|
|
574
|
+
const junctionName = args[0].text;
|
|
575
|
+
errors.push(...validateRefArgument(args[0], doc, { requireDbTable: true }));
|
|
576
|
+
if (errors.length > 0) return errors;
|
|
577
|
+
const junctionUnwound = doc.unwindType(junctionName);
|
|
578
|
+
if (!junctionUnwound) return errors;
|
|
579
|
+
const junctionDef = junctionUnwound.def;
|
|
580
|
+
if (!isInterface(junctionDef)) return errors;
|
|
581
|
+
const thisTypeName = getParentTypeName(token);
|
|
582
|
+
const targetTypeName = getNavTargetTypeName(field);
|
|
583
|
+
if (!thisTypeName || !targetTypeName) return errors;
|
|
584
|
+
const fksToThis = findFKFieldsPointingTo(junctionUnwound.doc, junctionDef, thisTypeName);
|
|
585
|
+
if (fksToThis.length === 0) errors.push({
|
|
586
|
+
message: `Junction '${junctionName}' has no @db.rel.FK pointing to '${thisTypeName}'`,
|
|
587
|
+
severity: 1,
|
|
588
|
+
range: args[0].range
|
|
589
|
+
});
|
|
590
|
+
else if (fksToThis.length > 1) errors.push({
|
|
591
|
+
message: `Junction '${junctionName}' has multiple @db.rel.FK pointing to '${thisTypeName}' — not supported`,
|
|
592
|
+
severity: 1,
|
|
593
|
+
range: args[0].range
|
|
594
|
+
});
|
|
595
|
+
if (targetTypeName !== thisTypeName) {
|
|
596
|
+
const fksToTarget = findFKFieldsPointingTo(junctionUnwound.doc, junctionDef, targetTypeName);
|
|
597
|
+
if (fksToTarget.length === 0) errors.push({
|
|
598
|
+
message: `Junction '${junctionName}' has no @db.rel.FK pointing to '${targetTypeName}'`,
|
|
599
|
+
severity: 1,
|
|
600
|
+
range: args[0].range
|
|
601
|
+
});
|
|
602
|
+
else if (fksToTarget.length > 1) errors.push({
|
|
603
|
+
message: `Junction '${junctionName}' has multiple @db.rel.FK pointing to '${targetTypeName}' — not supported`,
|
|
604
|
+
severity: 1,
|
|
605
|
+
range: args[0].range
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
return errors;
|
|
609
|
+
}
|
|
610
|
+
}),
|
|
611
|
+
filter: new AnnotationSpec({
|
|
612
|
+
description: "Applies a filter to a navigational property, restricting which related records are loaded.\n\n**Example:**\n```atscript\n@db.rel.from\n@db.rel.filter `Post.published = true`\npublishedPosts: Post[]\n```\n",
|
|
613
|
+
nodeType: ["prop"],
|
|
614
|
+
argument: {
|
|
615
|
+
name: "condition",
|
|
616
|
+
type: "query",
|
|
617
|
+
description: "Filter expression restricting which related records are loaded."
|
|
618
|
+
},
|
|
619
|
+
validate(token, args, doc) {
|
|
620
|
+
const errors = [];
|
|
621
|
+
const field = token.parentNode;
|
|
622
|
+
const hasTo = field.countAnnotations("db.rel.to") > 0;
|
|
623
|
+
const hasFrom = field.countAnnotations("db.rel.from") > 0;
|
|
624
|
+
const hasVia = field.countAnnotations("db.rel.via") > 0;
|
|
625
|
+
if (!hasTo && !hasFrom && !hasVia) {
|
|
626
|
+
errors.push({
|
|
627
|
+
message: "@db.rel.filter is only valid on navigational fields (@db.rel.to, @db.rel.from, or @db.rel.via)",
|
|
628
|
+
severity: 1,
|
|
629
|
+
range: token.range
|
|
630
|
+
});
|
|
631
|
+
return errors;
|
|
632
|
+
}
|
|
633
|
+
if (!args[0]?.queryNode) return errors;
|
|
634
|
+
const targetTypeName = getNavTargetTypeName(field);
|
|
635
|
+
if (!targetTypeName) return errors;
|
|
636
|
+
const allowedTypes = [targetTypeName];
|
|
637
|
+
if (hasVia) {
|
|
638
|
+
const junctionType = getAnnotationAlias(field, "db.rel.via");
|
|
639
|
+
if (junctionType) allowedTypes.push(junctionType);
|
|
640
|
+
}
|
|
641
|
+
errors.push(...validateQueryScope(args[0], allowedTypes, targetTypeName, doc));
|
|
642
|
+
return errors;
|
|
643
|
+
}
|
|
644
|
+
})
|
|
645
|
+
} };
|
|
646
|
+
|
|
647
|
+
//#endregion
|
|
648
|
+
//#region packages/db/src/plugin/annotations/search.ts
|
|
649
|
+
const dbSearchAnnotations = { search: {
|
|
650
|
+
vector: {
|
|
651
|
+
$self: new AnnotationSpec({
|
|
652
|
+
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** → JSON storage (no native vector support)\n\n**Example:**\n```atscript\n@db.search.vector 1536, \"cosine\"\nembedding: db.vector\n```\n",
|
|
653
|
+
nodeType: ["prop"],
|
|
654
|
+
multiple: false,
|
|
655
|
+
argument: [
|
|
656
|
+
{
|
|
657
|
+
optional: false,
|
|
658
|
+
name: "dimensions",
|
|
659
|
+
type: "number",
|
|
660
|
+
description: "The **number of dimensions in the vector** (must match your embedding model output).",
|
|
661
|
+
values: [
|
|
662
|
+
"512",
|
|
663
|
+
"768",
|
|
664
|
+
"1024",
|
|
665
|
+
"1536",
|
|
666
|
+
"3072",
|
|
667
|
+
"4096"
|
|
668
|
+
]
|
|
669
|
+
},
|
|
670
|
+
{
|
|
671
|
+
optional: true,
|
|
672
|
+
name: "similarity",
|
|
673
|
+
type: "string",
|
|
674
|
+
description: "The **similarity metric** for vector search. Defaults to `\"cosine\"`.\n\n**Available options:** `\"cosine\"`, `\"euclidean\"`, `\"dotProduct\"`.",
|
|
675
|
+
values: [
|
|
676
|
+
"cosine",
|
|
677
|
+
"euclidean",
|
|
678
|
+
"dotProduct"
|
|
679
|
+
]
|
|
680
|
+
},
|
|
681
|
+
{
|
|
682
|
+
optional: true,
|
|
683
|
+
name: "indexName",
|
|
684
|
+
type: "string",
|
|
685
|
+
description: "The **name of the vector search index** (optional, defaults to the field name)."
|
|
686
|
+
}
|
|
687
|
+
]
|
|
688
|
+
}),
|
|
689
|
+
threshold: new AnnotationSpec({
|
|
690
|
+
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).\n\n**Example:**\n```atscript\n@db.search.vector 1536, \"cosine\"\n@db.search.vector.threshold 0.7\nembedding: db.vector\n```\n",
|
|
691
|
+
nodeType: ["prop"],
|
|
692
|
+
multiple: false,
|
|
693
|
+
argument: {
|
|
694
|
+
optional: false,
|
|
695
|
+
name: "value",
|
|
696
|
+
type: "number",
|
|
697
|
+
description: "Minimum similarity score (`0`–`1`). Results below this threshold are excluded."
|
|
698
|
+
}
|
|
699
|
+
})
|
|
700
|
+
},
|
|
701
|
+
filter: new AnnotationSpec({
|
|
702
|
+
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`.\n\n**Example:**\n```atscript\n@db.search.vector 1536, \"cosine\"\nembedding: db.vector\n\n@db.search.filter \"embedding\"\ncategory: string\n```\n",
|
|
703
|
+
nodeType: ["prop"],
|
|
704
|
+
multiple: true,
|
|
705
|
+
argument: {
|
|
706
|
+
optional: false,
|
|
707
|
+
name: "indexName",
|
|
708
|
+
type: "string",
|
|
709
|
+
description: "The **name of the vector search index** (field name or explicit indexName from `@db.search.vector`) this field filters."
|
|
710
|
+
}
|
|
711
|
+
})
|
|
712
|
+
} };
|
|
713
|
+
|
|
714
|
+
//#endregion
|
|
715
|
+
//#region packages/db/src/plugin/annotations/table.ts
|
|
716
|
+
const dbTableAnnotations = {
|
|
717
|
+
table: {
|
|
718
|
+
$self: new AnnotationSpec({
|
|
719
|
+
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.\n\n**Example:**\n```atscript\n@db.table \"users\"\nexport interface User { ... }\n```\n",
|
|
720
|
+
nodeType: ["interface"],
|
|
721
|
+
argument: {
|
|
722
|
+
optional: true,
|
|
723
|
+
name: "name",
|
|
724
|
+
type: "string",
|
|
725
|
+
description: "Table/collection name. If omitted, derived from interface name."
|
|
726
|
+
},
|
|
727
|
+
validate(token, _args, _doc) {
|
|
728
|
+
const errors = [];
|
|
729
|
+
const owner = token.parentNode;
|
|
730
|
+
if (hasAnyViewAnnotation(owner)) errors.push({
|
|
731
|
+
message: "An interface cannot be both a @db.table and a @db.view",
|
|
732
|
+
severity: 1,
|
|
733
|
+
range: token.range
|
|
734
|
+
});
|
|
735
|
+
return errors;
|
|
736
|
+
}
|
|
737
|
+
}),
|
|
738
|
+
renamed: new AnnotationSpec({
|
|
739
|
+
description: "Specifies the previous table name for table rename migration. The sync engine generates ALTER TABLE RENAME instead of drop+create.\n\n**Example:**\n```atscript\n@db.table \"app_users\"\n@db.table.renamed \"users\"\nexport interface User { ... }\n```\n",
|
|
740
|
+
nodeType: ["interface"],
|
|
741
|
+
argument: {
|
|
742
|
+
name: "oldName",
|
|
743
|
+
type: "string",
|
|
744
|
+
description: "The previous table/collection name."
|
|
745
|
+
},
|
|
746
|
+
validate(token, _args, _doc) {
|
|
747
|
+
const errors = [];
|
|
748
|
+
const owner = token.parentNode;
|
|
749
|
+
if (owner.countAnnotations("db.table") === 0) errors.push({
|
|
750
|
+
message: "@db.table.renamed requires @db.table on the same interface",
|
|
751
|
+
severity: 1,
|
|
752
|
+
range: token.range
|
|
753
|
+
});
|
|
754
|
+
return errors;
|
|
755
|
+
}
|
|
756
|
+
})
|
|
757
|
+
},
|
|
758
|
+
schema: new AnnotationSpec({
|
|
759
|
+
description: "Assigns the entity to a database schema/namespace.\n\n**Example:**\n```atscript\n@db.table \"users\"\n@db.schema \"auth\"\nexport interface User { ... }\n```\n",
|
|
760
|
+
nodeType: ["interface"],
|
|
761
|
+
argument: {
|
|
762
|
+
name: "name",
|
|
763
|
+
type: "string",
|
|
764
|
+
description: "Schema/namespace name."
|
|
765
|
+
}
|
|
766
|
+
}),
|
|
767
|
+
sync: { method: new AnnotationSpec({
|
|
768
|
+
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.\n\n**Example:**\n```atscript\n@db.sync.method \"drop\"\ninterface Logs { ... }\n```\n",
|
|
769
|
+
nodeType: ["interface"],
|
|
770
|
+
argument: {
|
|
771
|
+
name: "method",
|
|
772
|
+
type: "string",
|
|
773
|
+
description: "Sync method: \"drop\" (lossy) or \"recreate\" (lossless with data copy).",
|
|
774
|
+
values: ["drop", "recreate"]
|
|
775
|
+
}
|
|
776
|
+
}) }
|
|
777
|
+
};
|
|
778
|
+
|
|
779
|
+
//#endregion
|
|
780
|
+
//#region packages/db/src/plugin/annotations/view.ts
|
|
781
|
+
const dbViewAnnotations = { view: {
|
|
782
|
+
$self: new AnnotationSpec({
|
|
783
|
+
description: "Marks an interface as a **database view**. Optionally takes a view name argument.\n\n**Example:**\n```atscript\n@db.view \"active_premium_users\"\n@db.view.for User\nexport interface ActivePremiumUser { ... }\n```\n",
|
|
784
|
+
nodeType: ["interface"],
|
|
785
|
+
argument: {
|
|
786
|
+
optional: true,
|
|
787
|
+
name: "name",
|
|
788
|
+
type: "string",
|
|
789
|
+
description: "The view name in the database. If omitted, derived from the interface name."
|
|
790
|
+
},
|
|
791
|
+
validate(token, _args, _doc) {
|
|
792
|
+
const errors = [];
|
|
793
|
+
const owner = token.parentNode;
|
|
794
|
+
if (owner.countAnnotations("db.table") > 0) errors.push({
|
|
795
|
+
message: "An interface cannot be both a @db.table and a @db.view",
|
|
796
|
+
severity: 1,
|
|
797
|
+
range: token.range
|
|
798
|
+
});
|
|
799
|
+
return errors;
|
|
800
|
+
}
|
|
801
|
+
}),
|
|
802
|
+
for: new AnnotationSpec({
|
|
803
|
+
description: "Specifies the entry/primary table for a computed view. Required for views that map fields via chain refs.\n\n**Example:**\n```atscript\n@db.view.for Order\n@db.view.filter `Order.status = 'active'`\nexport interface ActiveOrderDetails { ... }\n```\n",
|
|
804
|
+
nodeType: ["interface"],
|
|
805
|
+
argument: {
|
|
806
|
+
name: "entry",
|
|
807
|
+
type: "ref",
|
|
808
|
+
description: "The primary/entry table type (must have @db.table)."
|
|
809
|
+
},
|
|
810
|
+
validate(token, args, doc) {
|
|
811
|
+
const errors = [];
|
|
812
|
+
const owner = token.parentNode;
|
|
813
|
+
if (owner.countAnnotations("db.table") > 0) errors.push({
|
|
814
|
+
message: "An interface cannot be both a @db.table and a @db.view",
|
|
815
|
+
severity: 1,
|
|
816
|
+
range: token.range
|
|
817
|
+
});
|
|
818
|
+
if (args[0]) errors.push(...validateRefArgument(args[0], doc, { requireDbTable: true }));
|
|
819
|
+
return errors;
|
|
820
|
+
}
|
|
821
|
+
}),
|
|
822
|
+
joins: new AnnotationSpec({
|
|
823
|
+
description: "Declares an explicit join for a view. Use when no `@db.rel.*` path exists between the entry table and the target.\n\n**Example:**\n```atscript\n@db.view.for Order\n@db.view.joins Warehouse, `Warehouse.regionId = Order.regionId`\nexport interface OrderWarehouse { ... }\n```\n",
|
|
824
|
+
nodeType: ["interface"],
|
|
825
|
+
multiple: true,
|
|
826
|
+
mergeStrategy: "append",
|
|
827
|
+
argument: [{
|
|
828
|
+
name: "target",
|
|
829
|
+
type: "ref",
|
|
830
|
+
description: "The table type to join (must have @db.table)."
|
|
831
|
+
}, {
|
|
832
|
+
name: "condition",
|
|
833
|
+
type: "query",
|
|
834
|
+
description: "Join condition expression."
|
|
835
|
+
}],
|
|
836
|
+
validate(token, args, doc) {
|
|
837
|
+
const errors = [];
|
|
838
|
+
const owner = token.parentNode;
|
|
839
|
+
if (!hasAnyViewAnnotation(owner) && !args[0]) {
|
|
840
|
+
errors.push({
|
|
841
|
+
message: "@db.view.joins is only valid on @db.view interfaces",
|
|
842
|
+
severity: 1,
|
|
843
|
+
range: token.range
|
|
844
|
+
});
|
|
845
|
+
return errors;
|
|
846
|
+
}
|
|
847
|
+
if (args[0]) errors.push(...validateRefArgument(args[0], doc, { requireDbTable: true }));
|
|
848
|
+
const entryTypeName = getAnnotationAlias(owner, "db.view.for");
|
|
849
|
+
if (!entryTypeName) {
|
|
850
|
+
errors.push({
|
|
851
|
+
message: "@db.view.joins requires @db.view.for to identify the entry table",
|
|
852
|
+
severity: 1,
|
|
853
|
+
range: token.range
|
|
854
|
+
});
|
|
855
|
+
return errors;
|
|
856
|
+
}
|
|
857
|
+
if (args[1]?.queryNode && args[0]) {
|
|
858
|
+
const joinTargetName = args[0].text;
|
|
859
|
+
errors.push(...validateQueryScope(args[1], [joinTargetName, entryTypeName], entryTypeName, doc));
|
|
860
|
+
}
|
|
861
|
+
return errors;
|
|
862
|
+
}
|
|
863
|
+
}),
|
|
864
|
+
filter: new AnnotationSpec({
|
|
865
|
+
description: "WHERE clause for a view, filtering which rows are included.\n\n**Example:**\n```atscript\n@db.view.for User\n@db.view.filter `User.status = 'active' and User.age >= 18`\nexport interface ActiveUser { ... }\n```\n",
|
|
866
|
+
nodeType: ["interface"],
|
|
867
|
+
argument: {
|
|
868
|
+
name: "condition",
|
|
869
|
+
type: "query",
|
|
870
|
+
description: "Filter expression for the view WHERE clause."
|
|
871
|
+
},
|
|
872
|
+
validate(token, args, doc) {
|
|
873
|
+
const errors = [];
|
|
874
|
+
const owner = token.parentNode;
|
|
875
|
+
if (!hasAnyViewAnnotation(owner) && !args[0]) {
|
|
876
|
+
errors.push({
|
|
877
|
+
message: "@db.view.filter is only valid on @db.view interfaces",
|
|
878
|
+
severity: 1,
|
|
879
|
+
range: token.range
|
|
880
|
+
});
|
|
881
|
+
return errors;
|
|
882
|
+
}
|
|
883
|
+
if (!args[0]?.queryNode) return errors;
|
|
884
|
+
const entryTypeName = getAnnotationAlias(owner, "db.view.for");
|
|
885
|
+
if (!entryTypeName) {
|
|
886
|
+
errors.push({
|
|
887
|
+
message: "@db.view.filter requires @db.view.for to identify the entry table",
|
|
888
|
+
severity: 1,
|
|
889
|
+
range: token.range
|
|
890
|
+
});
|
|
891
|
+
return errors;
|
|
892
|
+
}
|
|
893
|
+
const allowedTypes = [entryTypeName];
|
|
894
|
+
const joinsAnnotations = owner.annotations?.filter((a) => a.name === "db.view.joins");
|
|
895
|
+
if (joinsAnnotations) {
|
|
896
|
+
for (const join of joinsAnnotations) if (join.args[0]) allowedTypes.push(join.args[0].text);
|
|
897
|
+
}
|
|
898
|
+
errors.push(...validateQueryScope(args[0], allowedTypes, entryTypeName, doc));
|
|
899
|
+
return errors;
|
|
900
|
+
}
|
|
901
|
+
}),
|
|
902
|
+
materialized: new AnnotationSpec({
|
|
903
|
+
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.\n\n**Example:**\n```atscript\n@db.view.materialized\n@db.view.for User\n@db.view.filter `User.status = 'active'`\nexport interface ActiveUsers { ... }\n```\n",
|
|
904
|
+
nodeType: ["interface"],
|
|
905
|
+
validate(token, _args, _doc) {
|
|
906
|
+
const errors = [];
|
|
907
|
+
const owner = token.parentNode;
|
|
908
|
+
if (!hasAnyViewAnnotation(owner)) errors.push({
|
|
909
|
+
message: "@db.view.materialized is only valid on @db.view interfaces",
|
|
910
|
+
severity: 1,
|
|
911
|
+
range: token.range
|
|
912
|
+
});
|
|
913
|
+
return errors;
|
|
914
|
+
}
|
|
915
|
+
}),
|
|
916
|
+
renamed: new AnnotationSpec({
|
|
917
|
+
description: "Specifies the previous view name for view rename migration. The sync engine drops the old view and creates the new one.\n\n**Example:**\n```atscript\n@db.view \"active_premium_users\"\n@db.view.renamed \"active_users\"\n@db.view.for User\nexport interface ActivePremiumUser { ... }\n```\n",
|
|
918
|
+
nodeType: ["interface"],
|
|
919
|
+
argument: {
|
|
920
|
+
name: "oldName",
|
|
921
|
+
type: "string",
|
|
922
|
+
description: "The previous view name."
|
|
923
|
+
},
|
|
924
|
+
validate(token, _args, _doc) {
|
|
925
|
+
const errors = [];
|
|
926
|
+
const owner = token.parentNode;
|
|
927
|
+
if (!hasAnyViewAnnotation(owner)) errors.push({
|
|
928
|
+
message: "@db.view.renamed requires @db.view on the same interface",
|
|
929
|
+
severity: 1,
|
|
930
|
+
range: token.range
|
|
931
|
+
});
|
|
932
|
+
return errors;
|
|
933
|
+
}
|
|
934
|
+
}),
|
|
935
|
+
having: new AnnotationSpec({
|
|
936
|
+
description: "Post-aggregation filter (HAVING clause) for analytical views. References view field aliases with applied aggregate functions.\n\n**Example:**\n```atscript\n@db.view\n@db.view.for Order\n@db.view.having `totalRevenue > 100`\nexport interface TopCategories { ... }\n```\n",
|
|
937
|
+
nodeType: ["interface"],
|
|
938
|
+
argument: {
|
|
939
|
+
name: "condition",
|
|
940
|
+
type: "query",
|
|
941
|
+
description: "HAVING condition referencing view aliases."
|
|
942
|
+
},
|
|
943
|
+
validate(token, _args, _doc) {
|
|
944
|
+
const errors = [];
|
|
945
|
+
const owner = token.parentNode;
|
|
946
|
+
if (!hasAnyViewAnnotation(owner)) errors.push({
|
|
947
|
+
message: "@db.view.having is only valid on @db.view interfaces",
|
|
948
|
+
severity: 1,
|
|
949
|
+
range: token.range
|
|
950
|
+
});
|
|
951
|
+
return errors;
|
|
952
|
+
}
|
|
953
|
+
})
|
|
954
|
+
} };
|
|
955
|
+
|
|
956
|
+
//#endregion
|
|
957
|
+
//#region packages/db/src/plugin/index.ts
|
|
958
|
+
const dbPlugin = () => ({
|
|
959
|
+
name: "db",
|
|
960
|
+
config() {
|
|
961
|
+
return {
|
|
962
|
+
annotations: { db: {
|
|
963
|
+
patch: dbColumnAnnotations.patch,
|
|
964
|
+
table: dbTableAnnotations.table,
|
|
965
|
+
schema: dbTableAnnotations.schema,
|
|
966
|
+
index: dbIndexAnnotations.index,
|
|
967
|
+
column: dbColumnAnnotations.column,
|
|
968
|
+
default: dbColumnAnnotations.default,
|
|
969
|
+
json: dbColumnAnnotations.json,
|
|
970
|
+
ignore: dbColumnAnnotations.ignore,
|
|
971
|
+
sync: dbTableAnnotations.sync,
|
|
972
|
+
rel: dbRelAnnotations.rel,
|
|
973
|
+
view: dbViewAnnotations.view,
|
|
974
|
+
agg: dbAggAnnotations.agg,
|
|
975
|
+
search: dbSearchAnnotations.search
|
|
976
|
+
} },
|
|
977
|
+
primitives: { db: { extensions: { vector: {
|
|
978
|
+
type: {
|
|
979
|
+
kind: "array",
|
|
980
|
+
of: "number"
|
|
981
|
+
},
|
|
982
|
+
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\n\n**Example:**\n```atscript\n@db.search.vector 1536, \"cosine\"\nembedding: db.vector\n```\n"
|
|
983
|
+
} } } }
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
//#endregion
|
|
989
|
+
export { dbPlugin, dbPlugin as default };
|