@sqldoc/atlas 0.0.1

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/src/index.ts ADDED
@@ -0,0 +1,97 @@
1
+ // @sqldoc/atlas -- Atlas WASI integration for sqldoc
2
+ // Schema types, database adapters, and WASI runner
3
+
4
+ import * as fs from 'node:fs'
5
+ import * as path from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+ import { createDockerAdapter } from './db/docker.ts'
8
+ import { createPgliteAdapter } from './db/pglite.ts'
9
+ import { createPostgresAdapter } from './db/postgres.ts'
10
+ import { extractExtensions, validatePgliteExtensions, validatePostgresExtensions } from './extensions.ts'
11
+ import { createAtlasRunner } from './runner.ts'
12
+
13
+ export { createDockerAdapter } from './db/docker.ts'
14
+ export { createPgliteAdapter } from './db/pglite.ts'
15
+ export { createPostgresAdapter } from './db/postgres.ts'
16
+ export type { DatabaseAdapter, ExecResult, QueryResult } from './db/types.ts'
17
+ export { extractExtensions, validatePgliteExtensions, validatePostgresExtensions } from './extensions.ts'
18
+ export type { AtlasRunner, AtlasRunnerOptions } from './runner.ts'
19
+ export { createAtlasRunner } from './runner.ts'
20
+ export * from './types.ts'
21
+
22
+ export interface CreateRunnerConfig {
23
+ /** SQL dialect. Default: 'postgres' */
24
+ dialect?: 'postgres' | 'mysql' | 'sqlite'
25
+ /** Database connection URL. If omitted, uses pglite (in-memory postgres). */
26
+ devUrl?: string
27
+ /** SQL file contents to scan for CREATE EXTENSION statements */
28
+ sqlFiles?: string[]
29
+ }
30
+
31
+ /**
32
+ * Resolve the atlas.wasm binary.
33
+ * Binary mode: ATLAS_WASM_PATH env var set by binary entry point.
34
+ * Dev mode: walk up from current directory to find atlas.wasm.
35
+ */
36
+ function resolveWasm(): string {
37
+ // Binary mode: WASM path set by binary entry point
38
+ if (process.env.ATLAS_WASM_PATH) {
39
+ return process.env.ATLAS_WASM_PATH
40
+ }
41
+
42
+ // Dev mode: walk up from current directory to find atlas.wasm
43
+ let dir = path.dirname(fileURLToPath(import.meta.url))
44
+ while (true) {
45
+ for (const candidate of [
46
+ path.join(dir, 'wasm', 'atlas.wasm'),
47
+ path.join(dir, '..', 'wasm', 'atlas.wasm'),
48
+ path.join(dir, 'node_modules', '@sqldoc', 'atlas', 'wasm', 'atlas.wasm'),
49
+ path.join(dir, 'packages', 'atlas', 'wasm', 'atlas.wasm'),
50
+ ]) {
51
+ if (fs.existsSync(candidate)) return candidate
52
+ }
53
+ const parent = path.dirname(dir)
54
+ if (parent === dir) break
55
+ dir = parent
56
+ }
57
+ throw new Error(
58
+ 'atlas.wasm not found. Set ATLAS_WASM_PATH or build: cd atlas/cmd/atlas-wasi && GOOS=wasip1 GOARCH=wasm go build -o atlas.wasm .',
59
+ )
60
+ }
61
+
62
+ /**
63
+ * Create an Atlas runner with sensible defaults.
64
+ * Resolves the wasm binary, detects extensions from SQL files,
65
+ * validates them, and creates the appropriate DB adapter.
66
+ */
67
+ export async function createRunner(config: CreateRunnerConfig = {}): Promise<import('./runner').AtlasRunner> {
68
+ const wasmPath = resolveWasm()
69
+ const dialect = config.dialect ?? 'postgres'
70
+ const devUrl = config.devUrl ?? 'pglite'
71
+
72
+ // Extract extensions from SQL (postgres only)
73
+ const { extensions } =
74
+ dialect === 'postgres' && config.sqlFiles ? extractExtensions(config.sqlFiles) : { extensions: [] }
75
+
76
+ let db: import('./db/types').DatabaseAdapter
77
+
78
+ if (devUrl.startsWith('docker://') || devUrl.startsWith('dockerfile://')) {
79
+ db = await createDockerAdapter(devUrl)
80
+ // Docker postgres — validate extensions are available
81
+ if (extensions.length > 0) {
82
+ await validatePostgresExtensions(extensions, (sql) => db.query(sql))
83
+ }
84
+ } else if (devUrl.startsWith('postgres://') || devUrl.startsWith('postgresql://')) {
85
+ db = await createPostgresAdapter(devUrl)
86
+ // External postgres — validate extensions are available
87
+ if (extensions.length > 0) {
88
+ await validatePostgresExtensions(extensions, (sql) => db.query(sql))
89
+ }
90
+ } else {
91
+ // pglite — validate and load extensions
92
+ const validExtensions = extensions.length > 0 ? await validatePgliteExtensions(extensions) : []
93
+ db = await createPgliteAdapter(validExtensions.length > 0 ? validExtensions : undefined)
94
+ }
95
+
96
+ return createAtlasRunner({ wasmPath, db })
97
+ }
package/src/runner.ts ADDED
@@ -0,0 +1,263 @@
1
+ /**
2
+ * High-level Atlas runner API.
3
+ *
4
+ * Provides inspect() and diff() functions that:
5
+ * 1. Spawn a worker thread running the Atlas WASI module
6
+ * 2. Handle the SharedArrayBuffer bridge loop on the main thread
7
+ * 3. Execute SQL requests from the WASI module via the DatabaseAdapter
8
+ * 4. Return parsed AtlasResult
9
+ *
10
+ * The compiled WebAssembly.Module is cached across commands.
11
+ */
12
+
13
+ import * as fs from 'node:fs'
14
+ import { createRequire } from 'node:module'
15
+ import * as path from 'node:path'
16
+ import { fileURLToPath } from 'node:url'
17
+ import { Worker } from 'node:worker_threads'
18
+ import {
19
+ type BridgeBuffers,
20
+ bridgeReadRequest,
21
+ bridgeRespond,
22
+ bridgeWaitForSignal,
23
+ createBridgeBuffers,
24
+ SIGNAL_DONE,
25
+ SIGNAL_REQUEST,
26
+ } from './bridge.ts'
27
+ import type { DatabaseAdapter } from './db/types.ts'
28
+ import type { AtlasCommand, AtlasRename, AtlasResult } from './types.ts'
29
+
30
+ export interface AtlasRunnerOptions {
31
+ /** Path to atlas.wasm binary */
32
+ wasmPath: string
33
+ /** Database adapter (pglite or pg) */
34
+ db: DatabaseAdapter
35
+ }
36
+
37
+ export interface AtlasRunner {
38
+ /** Execute SQL files and return parsed schema with tags */
39
+ inspect(files: string[], options?: { schema?: string; dialect?: string; fileNames?: string[] }): Promise<AtlasResult>
40
+
41
+ /** Compare two schema states and return migration SQL */
42
+ diff(
43
+ from: string[],
44
+ to: string[],
45
+ options?: { schema?: string; dialect?: string; renames?: AtlasRename[] },
46
+ ): Promise<AtlasResult>
47
+
48
+ /** Clean up resources */
49
+ close(): Promise<void>
50
+ }
51
+
52
+ /**
53
+ * Resolve the path to the worker file (.js or .ts).
54
+ *
55
+ * Prefers compiled .js (production / Bun binary). Falls back to raw .ts
56
+ * for workspace development (vitest, tsx, Node --experimental-strip-types).
57
+ */
58
+ function resolveWorkerPath(): { workerPath: string; execArgv: string[] } {
59
+ const thisDir = path.dirname(fileURLToPath(import.meta.url))
60
+ const candidates = [
61
+ path.resolve(thisDir, 'worker.js'), // dist/worker.js (production)
62
+ path.resolve(thisDir, '../dist/worker.js'), // src/../dist/worker.js (legacy)
63
+ path.resolve(thisDir, 'worker.ts'), // src/worker.ts (raw .ts dev)
64
+ ]
65
+
66
+ const isBun = typeof (globalThis as any).Bun !== 'undefined'
67
+
68
+ for (const candidate of candidates) {
69
+ if (fs.existsSync(candidate)) {
70
+ if (candidate.endsWith('.ts') && !isBun) {
71
+ // Node 22.21+ runs .ts natively — no loader needed for workers
72
+ // Older Node needs tsx/cjs to register TypeScript transform
73
+ const [major, minor] = (process.versions?.node ?? '0.0').split('.').map(Number)
74
+ if (major > 22 || (major === 22 && minor >= 21)) {
75
+ return { workerPath: candidate, execArgv: [] }
76
+ }
77
+ const atlasRequire = createRequire(candidate)
78
+ const tsxCjs = atlasRequire.resolve('tsx/cjs')
79
+ return { workerPath: candidate, execArgv: ['--require', tsxCjs] }
80
+ }
81
+ return { workerPath: candidate, execArgv: [] }
82
+ }
83
+ }
84
+
85
+ throw new Error(`Cannot find worker file.\nLooked in:\n${candidates.map((c) => ` ${c}`).join('\n')}`)
86
+ }
87
+
88
+ /**
89
+ * Run a single Atlas command via a worker thread.
90
+ *
91
+ * Main thread bridge loop:
92
+ * 1. Wait for worker to signal a request via Atomics
93
+ * 2. Read SQL from shared buffer
94
+ * 3. Execute via DatabaseAdapter
95
+ * 4. Write response to shared buffer
96
+ * 5. Repeat until DONE signal
97
+ */
98
+ async function runCommand(wasmPath: string, db: DatabaseAdapter, command: AtlasCommand): Promise<AtlasResult> {
99
+ const buffers = createBridgeBuffers()
100
+ const stdinData = JSON.stringify(command)
101
+ const { workerPath, execArgv } = resolveWorkerPath()
102
+
103
+ return new Promise<AtlasResult>((resolve, reject) => {
104
+ const worker = new Worker(workerPath, {
105
+ workerData: {
106
+ wasmPath,
107
+ controlBuffer: buffers.control,
108
+ dataBuffer: buffers.data,
109
+ stdinData,
110
+ },
111
+ execArgv,
112
+ })
113
+
114
+ let settled = false
115
+
116
+ worker.on('message', (msg: { type: string; stdout?: string; error?: string }) => {
117
+ if (settled) return
118
+ settled = true
119
+
120
+ if (msg.type === 'error') {
121
+ reject(new Error(`Atlas worker error: ${msg.error}`))
122
+ } else if (msg.type === 'result') {
123
+ try {
124
+ const stdout = (msg.stdout ?? '').trim()
125
+ if (!stdout) {
126
+ resolve({})
127
+ } else {
128
+ resolve(JSON.parse(stdout))
129
+ }
130
+ } catch (_err: unknown) {
131
+ reject(new Error(`Failed to parse Atlas output: ${msg.stdout}`))
132
+ }
133
+ }
134
+ })
135
+
136
+ worker.on('error', (err) => {
137
+ if (settled) return
138
+ settled = true
139
+ reject(err)
140
+ })
141
+
142
+ worker.on('exit', (code) => {
143
+ if (settled) return
144
+ settled = true
145
+ if (code !== 0) {
146
+ reject(new Error(`Atlas worker exited with code ${code}`))
147
+ }
148
+ })
149
+
150
+ // Start the bridge loop (runs on main thread, async)
151
+ handleBridgeLoop(buffers, db).catch((err) => {
152
+ if (!settled) {
153
+ settled = true
154
+ worker.terminate()
155
+ reject(err)
156
+ }
157
+ })
158
+ })
159
+ }
160
+
161
+ /**
162
+ * Main-thread bridge loop: handles atlas_sql requests from the worker.
163
+ * Runs until the worker signals DONE.
164
+ */
165
+ async function handleBridgeLoop(buffers: BridgeBuffers, db: DatabaseAdapter): Promise<void> {
166
+ while (true) {
167
+ const signal = await bridgeWaitForSignal(buffers)
168
+
169
+ if (signal === SIGNAL_DONE) {
170
+ break
171
+ }
172
+
173
+ if (signal !== SIGNAL_REQUEST) {
174
+ // Unexpected signal -- wait again
175
+ // Reset to idle so bridgeWaitForSignal can poll again
176
+ const _control = new Int32Array(buffers.control)
177
+ // If it's RESPONSE, the worker hasn't reset yet. Wait briefly.
178
+ await new Promise((r) => setTimeout(r, 1))
179
+ continue
180
+ }
181
+
182
+ // Read SQL request from shared buffer
183
+ const reqJson = bridgeReadRequest(buffers)
184
+
185
+ let response: Record<string, unknown>
186
+ try {
187
+ const req = JSON.parse(reqJson) as { type: string; sql: string; args?: unknown[] }
188
+
189
+ if (req.type === 'query') {
190
+ const result = await db.query(req.sql, req.args)
191
+ response = { columns: result.columns, rows: result.rows }
192
+ } else {
193
+ const result = await db.exec(req.sql, req.args)
194
+ response = { rows_affected: result.rowsAffected }
195
+ }
196
+ } catch (err: unknown) {
197
+ const message = err instanceof Error ? err.message : String(err)
198
+ response = { error: message }
199
+ }
200
+
201
+ // Write response and notify worker (handle BigInt from pglite)
202
+ bridgeRespond(
203
+ buffers,
204
+ JSON.stringify(response, (_k, v) => (typeof v === 'bigint' ? v.toString() : v)),
205
+ )
206
+
207
+ // After responding, the worker will reset signal to IDLE.
208
+ // We need to wait for it to do so before calling bridgeWaitForSignal again.
209
+ // Small yield to allow worker to process.
210
+ await new Promise((r) => setTimeout(r, 0))
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Create an AtlasRunner instance.
216
+ *
217
+ * The runner caches the compiled WebAssembly.Module (compiles atlas.wasm once).
218
+ * Each inspect/diff call spawns a worker thread that reuses the cached module.
219
+ */
220
+ export async function createAtlasRunner(options: AtlasRunnerOptions): Promise<AtlasRunner> {
221
+ const { wasmPath, db } = options
222
+
223
+ // Verify wasm file exists
224
+ if (!fs.existsSync(wasmPath)) {
225
+ throw new Error(`Atlas WASM binary not found: ${wasmPath}`)
226
+ }
227
+
228
+ return {
229
+ async inspect(
230
+ files: string[],
231
+ opts?: { schema?: string; dialect?: string; fileNames?: string[] },
232
+ ): Promise<AtlasResult> {
233
+ const command: AtlasCommand = {
234
+ type: 'inspect',
235
+ dialect: (opts?.dialect as AtlasCommand['dialect']) ?? 'postgres',
236
+ files,
237
+ fileNames: opts?.fileNames,
238
+ schema: opts?.schema,
239
+ }
240
+ return runCommand(wasmPath, db, command)
241
+ },
242
+
243
+ async diff(
244
+ from: string[],
245
+ to: string[],
246
+ opts?: { schema?: string; dialect?: string; renames?: AtlasRename[] },
247
+ ): Promise<AtlasResult> {
248
+ const command: AtlasCommand = {
249
+ type: 'diff',
250
+ dialect: (opts?.dialect as AtlasCommand['dialect']) ?? 'postgres',
251
+ from,
252
+ to,
253
+ schema: opts?.schema,
254
+ renames: opts?.renames,
255
+ }
256
+ return runCommand(wasmPath, db, command)
257
+ },
258
+
259
+ async close(): Promise<void> {
260
+ await db.close()
261
+ },
262
+ }
263
+ }
package/src/types.ts ADDED
@@ -0,0 +1,305 @@
1
+ // ── Atlas WASI command/result types ─────────────────────────────────
2
+ // Hand-crafted from atlas/cmd/atlas-wasi/marshal.go (cycle-free flat types).
3
+ // Schema fields use lowercase json tags from the flat marshaler.
4
+ // Attr variants (Tag, Comment, Check) use PascalCase from map[string]string.
5
+
6
+ /**
7
+ * A known rename (from @docs.previously tags) to pass to Atlas before diffing.
8
+ */
9
+ export interface AtlasRename {
10
+ type: 'column' | 'table'
11
+ table: string
12
+ oldName: string
13
+ newName: string
14
+ }
15
+
16
+ /**
17
+ * A potential rename detected by Atlas during diff (drop+add pair with same type).
18
+ */
19
+ export interface AtlasRenameCandidate {
20
+ type: 'column' | 'table'
21
+ table: string
22
+ oldName: string
23
+ newName: string
24
+ colType?: string
25
+ }
26
+
27
+ /**
28
+ * Command sent to Atlas WASI module via stdin.
29
+ */
30
+ export interface AtlasCommand {
31
+ type: 'inspect' | 'diff' | 'apply'
32
+ dialect: 'postgres' | 'mysql' | 'sqlite'
33
+ schema?: string
34
+ files?: string[]
35
+ fileNames?: string[]
36
+ from?: string[]
37
+ to?: string[]
38
+ renames?: AtlasRename[]
39
+ }
40
+
41
+ /**
42
+ * A structured schema change description returned from Atlas diff.
43
+ */
44
+ export interface AtlasChange {
45
+ type:
46
+ | 'add_table'
47
+ | 'drop_table'
48
+ | 'rename_table'
49
+ | 'add_column'
50
+ | 'drop_column'
51
+ | 'rename_column'
52
+ | 'modify_column'
53
+ | 'add_index'
54
+ | 'drop_index'
55
+ | 'add_view'
56
+ | 'drop_view'
57
+ | 'add_function'
58
+ | 'drop_function'
59
+ table: string
60
+ name?: string
61
+ detail?: string
62
+ }
63
+
64
+ /**
65
+ * Result returned from Atlas WASI module via stdout.
66
+ * Top-level fields use lowercase json tags.
67
+ */
68
+ export interface AtlasResult {
69
+ schema?: AtlasRealm
70
+ statements?: string[]
71
+ changes?: AtlasChange[]
72
+ renameCandidates?: AtlasRenameCandidate[]
73
+ error?: string
74
+ }
75
+
76
+ // ── Schema types — lowercase (json tags in marshal.go flat types) ───
77
+
78
+ /**
79
+ * A Realm describes a domain of schema resources (physical database instance).
80
+ * Maps to flatRealm in marshal.go.
81
+ */
82
+ export interface AtlasRealm {
83
+ schemas: AtlasSchema[]
84
+ attrs?: AtlasAttr[]
85
+ }
86
+
87
+ /**
88
+ * A Schema describes a named database schema (e.g. "public").
89
+ * Maps to flatSchema in marshal.go.
90
+ */
91
+ export interface AtlasSchema {
92
+ name: string
93
+ tables?: AtlasTable[]
94
+ views?: AtlasView[]
95
+ funcs?: AtlasFunc[]
96
+ procs?: AtlasProc[]
97
+ attrs?: AtlasAttr[]
98
+ }
99
+
100
+ /**
101
+ * A Table represents a table definition.
102
+ * Maps to flatTable in marshal.go.
103
+ */
104
+ export interface AtlasTable {
105
+ name: string
106
+ columns?: AtlasColumn[]
107
+ indexes?: AtlasIndex[]
108
+ primary_key?: AtlasIndex
109
+ foreign_keys?: AtlasForeignKey[]
110
+ attrs?: AtlasAttr[]
111
+ triggers?: AtlasTrigger[]
112
+ }
113
+
114
+ /**
115
+ * A Column represents a column definition.
116
+ * Maps to flatColumn in marshal.go.
117
+ */
118
+ export interface AtlasColumn {
119
+ name: string
120
+ type?: AtlasColumnType
121
+ default?: AtlasExpr
122
+ attrs?: AtlasAttr[]
123
+ }
124
+
125
+ /**
126
+ * ColumnType represents a column type.
127
+ * Maps to flatColumnType in marshal.go.
128
+ * The `T` field is the type name (e.g., "bigint", "text").
129
+ */
130
+ export type TypeCategory =
131
+ | 'string'
132
+ | 'integer'
133
+ | 'float'
134
+ | 'decimal'
135
+ | 'boolean'
136
+ | 'time'
137
+ | 'binary'
138
+ | 'json'
139
+ | 'uuid'
140
+ | 'spatial'
141
+ | 'enum'
142
+ | 'composite'
143
+ | 'unknown'
144
+
145
+ export interface AtlasColumnType {
146
+ T?: string
147
+ raw?: string
148
+ null?: boolean
149
+ /** Normalized type category — dialect-independent */
150
+ category?: TypeCategory
151
+ /** Whether this is a user-defined type (enum, composite, domain) */
152
+ is_custom?: boolean
153
+ /** Enum values (when category is 'enum') */
154
+ enum_values?: string[]
155
+ /** Composite type fields (when category is 'composite') */
156
+ composite_fields?: Array<{ name: string; type: string }>
157
+ }
158
+
159
+ /**
160
+ * An Index represents an index definition.
161
+ * Maps to flatIndex in marshal.go.
162
+ */
163
+ export interface AtlasIndex {
164
+ name?: string
165
+ unique?: boolean
166
+ parts?: AtlasIndexPart[]
167
+ attrs?: AtlasAttr[]
168
+ }
169
+
170
+ /**
171
+ * An IndexPart represents a single part of an index.
172
+ * Maps to flatIndexPart in marshal.go.
173
+ */
174
+ export interface AtlasIndexPart {
175
+ column?: string
176
+ desc?: boolean
177
+ attrs?: AtlasAttr[]
178
+ }
179
+
180
+ /**
181
+ * A ForeignKey represents a foreign key constraint.
182
+ * Maps to flatForeignKey in marshal.go.
183
+ */
184
+ export interface AtlasForeignKey {
185
+ symbol?: string
186
+ columns?: string[]
187
+ ref_columns?: string[]
188
+ ref_table?: string
189
+ on_update?: string
190
+ on_delete?: string
191
+ }
192
+
193
+ /**
194
+ * A View represents a view definition.
195
+ * Maps to flatView in marshal.go.
196
+ */
197
+ export interface AtlasView {
198
+ name: string
199
+ def?: string
200
+ columns?: AtlasColumn[]
201
+ attrs?: AtlasAttr[]
202
+ }
203
+
204
+ /**
205
+ * A Func represents a function definition.
206
+ * Maps to flatFunc in marshal.go.
207
+ */
208
+ export interface AtlasFunc {
209
+ name: string
210
+ args?: AtlasFuncArg[]
211
+ ret?: AtlasColumnType
212
+ lang?: string
213
+ attrs?: AtlasAttr[]
214
+ }
215
+
216
+ export interface AtlasFuncArg {
217
+ name?: string
218
+ type?: AtlasColumnType
219
+ mode?: string
220
+ }
221
+
222
+ /**
223
+ * A Proc represents a procedure definition.
224
+ * Maps to flatProc in marshal.go.
225
+ */
226
+ export interface AtlasProc {
227
+ name: string
228
+ attrs?: AtlasAttr[]
229
+ }
230
+
231
+ /**
232
+ * A Trigger represents a trigger definition.
233
+ * Maps to flatTrigger in marshal.go.
234
+ */
235
+ export interface AtlasTrigger {
236
+ name: string
237
+ attrs?: AtlasAttr[]
238
+ }
239
+
240
+ /**
241
+ * An expression in schema DDL.
242
+ * Go serializes RawExpr as { X: string }, Literal as { V: string }.
243
+ */
244
+ export type AtlasExpr = { X: string } | { V: string } | unknown
245
+
246
+ // ── Attr union — PascalCase (map[string]string in Go marshal) ───────
247
+
248
+ /**
249
+ * Attrs is a heterogeneous array (Tag, Comment, Check all mixed).
250
+ * We model the known variants as a discriminated union with a fallback.
251
+ * Note: Attr fields use PascalCase (serialized via map[string]string in Go).
252
+ */
253
+ export type AtlasAttr = AtlasTag | AtlasComment | AtlasCheck | Record<string, unknown>
254
+
255
+ /**
256
+ * Tag attr. Serialized as { Name: string, Args: string }.
257
+ * PascalCase because Go marshal uses map[string]string{"Name": ..., "Args": ...}.
258
+ */
259
+ export interface AtlasTag {
260
+ Name: string
261
+ Args: string
262
+ }
263
+
264
+ /**
265
+ * Comment attr. Serialized as { Text: string }.
266
+ */
267
+ export interface AtlasComment {
268
+ Text: string
269
+ }
270
+
271
+ /**
272
+ * Check constraint attr. Serialized as { Name?: string, Expr: string }.
273
+ */
274
+ export interface AtlasCheck {
275
+ Name?: string
276
+ Expr: string
277
+ }
278
+
279
+ // ── Helper functions ────────────────────────────────────────────────
280
+
281
+ /**
282
+ * Type guard: returns true if the given attr is a Tag.
283
+ * Tags have `Name` and `Args` fields but NOT an `Expr` field
284
+ * (which would indicate a Check constraint instead).
285
+ */
286
+ export function isTag(attr: AtlasAttr): attr is AtlasTag {
287
+ return (
288
+ typeof attr === 'object' &&
289
+ attr !== null &&
290
+ 'Name' in attr &&
291
+ typeof (attr as AtlasTag).Name === 'string' &&
292
+ 'Args' in attr &&
293
+ typeof (attr as AtlasTag).Args === 'string' &&
294
+ !('Expr' in attr)
295
+ )
296
+ }
297
+
298
+ /**
299
+ * Extract all Tag attrs from a mixed Attrs array.
300
+ * Returns an empty array if attrs is undefined or empty.
301
+ */
302
+ export function findTags(attrs?: AtlasAttr[]): AtlasTag[] {
303
+ if (!attrs) return []
304
+ return attrs.filter(isTag)
305
+ }