@sqldoc/ns-history 0.1.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/package.json +33 -0
- package/src/index.ts +236 -0
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "module",
|
|
3
|
+
"name": "@sqldoc/ns-history",
|
|
4
|
+
"version": "0.1.1",
|
|
5
|
+
"description": "History namespace for sqldoc -- generates history tables and triggers for change tracking",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./src/index.ts",
|
|
9
|
+
"import": "./src/index.ts",
|
|
10
|
+
"default": "./src/index.ts"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"main": "./src/index.ts",
|
|
14
|
+
"types": "./src/index.ts",
|
|
15
|
+
"files": [
|
|
16
|
+
"src",
|
|
17
|
+
"!src/__tests__",
|
|
18
|
+
"package.json"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "node --test 'src/__tests__/**/*.test.ts'",
|
|
22
|
+
"typecheck": "tsgo -noEmit"
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"@sqldoc/core": "0.1.1"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@sqldoc/core": "0.1.1",
|
|
29
|
+
"@sqldoc/db": "0.1.1",
|
|
30
|
+
"@typescript/native-preview": "7.0.0-dev.20260405.1",
|
|
31
|
+
"@sqldoc/test-utils": "0.0.1"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* History namespace plugin -- generates history tables and triggers for change tracking.
|
|
3
|
+
*
|
|
4
|
+
* Creates a {table}_history table mirroring the source table's columns plus history metadata
|
|
5
|
+
* (history_id, valid_from, valid_to, history_operation). BEFORE UPDATE/DELETE triggers copy
|
|
6
|
+
* the OLD row into the history table.
|
|
7
|
+
*
|
|
8
|
+
* Postgres: PL/pgSQL function + multi-event BEFORE trigger.
|
|
9
|
+
* MySQL: Separate per-event BEFORE triggers with explicit column enumeration.
|
|
10
|
+
* SQLite: Separate per-event BEFORE triggers with explicit column enumeration.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { NamespacePlugin, SqlOutput, TagContext, TagOutput } from '@sqldoc/core'
|
|
14
|
+
import { autoIncrementType, currentTimestamp, type Dialect, quoteIdentifier, timestampType } from '@sqldoc/core'
|
|
15
|
+
|
|
16
|
+
// -- Minimal Atlas type shapes --
|
|
17
|
+
|
|
18
|
+
interface HistoryColumn {
|
|
19
|
+
name: string
|
|
20
|
+
type?: { T?: string; raw?: string; null?: boolean }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface HistoryTable {
|
|
24
|
+
name: string
|
|
25
|
+
columns?: HistoryColumn[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// -- Helper functions --
|
|
29
|
+
|
|
30
|
+
/** Get the raw SQL type string from an Atlas column type */
|
|
31
|
+
function columnTypeSql(col: HistoryColumn): string {
|
|
32
|
+
return col.type?.raw ?? col.type?.T ?? 'TEXT'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Generate the history table DDL mirroring source columns + metadata */
|
|
36
|
+
function generateHistoryTableSql(destination: string, columns: HistoryColumn[], dialect: Dialect): string {
|
|
37
|
+
const q = (name: string) => quoteIdentifier(name, dialect)
|
|
38
|
+
const colDefs = columns.map((col) => {
|
|
39
|
+
const nullable = col.type?.null !== false ? '' : ' NOT NULL'
|
|
40
|
+
return ` ${q(col.name)} ${columnTypeSql(col)}${nullable}`
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
// SQLite requires parentheses around expression defaults: DEFAULT (datetime('now'))
|
|
44
|
+
const defaultExpr = dialect === 'sqlite' ? `(${currentTimestamp(dialect)})` : currentTimestamp(dialect)
|
|
45
|
+
|
|
46
|
+
return `CREATE TABLE IF NOT EXISTS ${q(destination)} (
|
|
47
|
+
history_id ${autoIncrementType('bigint', dialect)} PRIMARY KEY,
|
|
48
|
+
${colDefs.join(',\n')},
|
|
49
|
+
valid_from ${timestampType(dialect)} NOT NULL DEFAULT ${defaultExpr},
|
|
50
|
+
valid_to ${timestampType(dialect)},
|
|
51
|
+
history_operation TEXT NOT NULL
|
|
52
|
+
);`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Generate Postgres PL/pgSQL function + multi-event BEFORE trigger */
|
|
56
|
+
function generatePostgresHistoryTriggers(
|
|
57
|
+
objectName: string,
|
|
58
|
+
destination: string,
|
|
59
|
+
operations: string[],
|
|
60
|
+
columns: HistoryColumn[],
|
|
61
|
+
): SqlOutput[] {
|
|
62
|
+
const ops = operations.map((op) => op.toUpperCase())
|
|
63
|
+
const triggerEvents = ops.join(' OR ')
|
|
64
|
+
const colNames = columns.map((c) => `"${c.name}"`).join(', ')
|
|
65
|
+
const oldRefs = columns.map((c) => `OLD."${c.name}"`).join(', ')
|
|
66
|
+
|
|
67
|
+
const fnSql = `CREATE OR REPLACE FUNCTION "${objectName}_history_fn"() RETURNS TRIGGER AS $$
|
|
68
|
+
BEGIN
|
|
69
|
+
INSERT INTO "${destination}" (${colNames}, valid_from, valid_to, history_operation)
|
|
70
|
+
VALUES (${oldRefs}, now(), now(), TG_OP);
|
|
71
|
+
IF TG_OP = 'DELETE' THEN
|
|
72
|
+
RETURN OLD;
|
|
73
|
+
END IF;
|
|
74
|
+
RETURN NEW;
|
|
75
|
+
END;
|
|
76
|
+
$$ LANGUAGE plpgsql;`
|
|
77
|
+
|
|
78
|
+
const triggerSql = `CREATE TRIGGER "${objectName}_history_trigger"
|
|
79
|
+
BEFORE ${triggerEvents} ON "${objectName}"
|
|
80
|
+
FOR EACH ROW EXECUTE FUNCTION "${objectName}_history_fn"();`
|
|
81
|
+
|
|
82
|
+
return [{ sql: fnSql }, { sql: triggerSql }]
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Generate separate per-event BEFORE triggers for MySQL/SQLite */
|
|
86
|
+
function generatePerEventHistoryTriggers(
|
|
87
|
+
objectName: string,
|
|
88
|
+
destination: string,
|
|
89
|
+
operations: string[],
|
|
90
|
+
columns: HistoryColumn[],
|
|
91
|
+
dialect: Dialect,
|
|
92
|
+
): SqlOutput[] {
|
|
93
|
+
const q = (name: string) => quoteIdentifier(name, dialect)
|
|
94
|
+
const ts = currentTimestamp(dialect)
|
|
95
|
+
const outputs: SqlOutput[] = []
|
|
96
|
+
|
|
97
|
+
const colNames = columns.map((c) => q(c.name)).join(', ')
|
|
98
|
+
|
|
99
|
+
for (const op of operations) {
|
|
100
|
+
const opLower = op.toLowerCase()
|
|
101
|
+
const opUpper = op.toUpperCase()
|
|
102
|
+
const triggerName = `${objectName}_history_before_${opLower}`
|
|
103
|
+
|
|
104
|
+
// BEFORE triggers always have OLD
|
|
105
|
+
const oldRefs = columns.map((c) => `OLD.${q(c.name)}`).join(', ')
|
|
106
|
+
|
|
107
|
+
outputs.push({
|
|
108
|
+
sql: `CREATE TRIGGER ${q(triggerName)}
|
|
109
|
+
BEFORE ${opUpper} ON ${q(objectName)}
|
|
110
|
+
FOR EACH ROW
|
|
111
|
+
BEGIN
|
|
112
|
+
INSERT INTO ${q(destination)} (${colNames}, valid_from, valid_to, history_operation)
|
|
113
|
+
VALUES (${oldRefs}, ${ts}, ${ts}, '${opUpper}');
|
|
114
|
+
END;`,
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return outputs
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// -- Plugin definition --
|
|
122
|
+
|
|
123
|
+
const plugin: NamespacePlugin = {
|
|
124
|
+
apiVersion: 1,
|
|
125
|
+
name: 'history',
|
|
126
|
+
tags: {
|
|
127
|
+
$self: {
|
|
128
|
+
description: 'Enable history tracking on this table (creates history table + triggers)',
|
|
129
|
+
targets: ['table'],
|
|
130
|
+
args: {
|
|
131
|
+
destination: { type: 'string' },
|
|
132
|
+
on: { type: 'array', items: { type: 'enum', values: ['update', 'delete'] } },
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
onTag(ctx: TagContext): TagOutput | undefined {
|
|
138
|
+
const { tag, objectName } = ctx
|
|
139
|
+
const dialect = ctx.dialect
|
|
140
|
+
|
|
141
|
+
if (tag.name !== '$self' && tag.name !== null) return undefined
|
|
142
|
+
|
|
143
|
+
const args = tag.args as Record<string, unknown>
|
|
144
|
+
const operations = (args.on as string[] | undefined) ?? ['update', 'delete']
|
|
145
|
+
const destination = (args.destination as string) || (ctx.config.destination as string) || `${objectName}_history`
|
|
146
|
+
|
|
147
|
+
// History table requires column info from Atlas for ALL dialects
|
|
148
|
+
const table = ctx.atlasTable as HistoryTable | undefined
|
|
149
|
+
const columns = table?.columns
|
|
150
|
+
|
|
151
|
+
if (!columns || columns.length === 0) {
|
|
152
|
+
return {
|
|
153
|
+
sql: [],
|
|
154
|
+
docs: {
|
|
155
|
+
relationships: [
|
|
156
|
+
{
|
|
157
|
+
from: objectName,
|
|
158
|
+
to: destination,
|
|
159
|
+
label: 'history',
|
|
160
|
+
style: 'dashed',
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
annotations: [
|
|
164
|
+
{
|
|
165
|
+
object: objectName,
|
|
166
|
+
text: 'History triggers require Tier 2 compilation (column enumeration needed for history table DDL)',
|
|
167
|
+
},
|
|
168
|
+
],
|
|
169
|
+
},
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// History table DDL
|
|
174
|
+
const historyTableSql: SqlOutput = { sql: generateHistoryTableSql(destination, columns, dialect) }
|
|
175
|
+
|
|
176
|
+
// Trigger generation
|
|
177
|
+
let triggerSqls: SqlOutput[]
|
|
178
|
+
|
|
179
|
+
if (dialect === 'postgres') {
|
|
180
|
+
triggerSqls = generatePostgresHistoryTriggers(objectName, destination, operations, columns)
|
|
181
|
+
} else {
|
|
182
|
+
triggerSqls = generatePerEventHistoryTriggers(objectName, destination, operations, columns, dialect)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const sql: SqlOutput[] = [historyTableSql, ...triggerSqls]
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
sql,
|
|
189
|
+
docs: {
|
|
190
|
+
relationships: [
|
|
191
|
+
{
|
|
192
|
+
from: objectName,
|
|
193
|
+
to: destination,
|
|
194
|
+
label: 'history',
|
|
195
|
+
style: 'dashed',
|
|
196
|
+
},
|
|
197
|
+
],
|
|
198
|
+
annotations: [
|
|
199
|
+
{
|
|
200
|
+
object: objectName,
|
|
201
|
+
text: `History tracked (${operations.join(', ')})`,
|
|
202
|
+
},
|
|
203
|
+
],
|
|
204
|
+
},
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
lintRules: [
|
|
209
|
+
{
|
|
210
|
+
name: 'history.require-history',
|
|
211
|
+
description: 'Tables should have a @history tag',
|
|
212
|
+
default: 'warn',
|
|
213
|
+
|
|
214
|
+
check(ctx) {
|
|
215
|
+
const diagnostics = []
|
|
216
|
+
for (const output of ctx.outputs) {
|
|
217
|
+
const tableObjects = output.fileTags.filter((obj) => obj.target === 'table' && !obj.objectName.includes('.'))
|
|
218
|
+
|
|
219
|
+
for (const obj of tableObjects) {
|
|
220
|
+
const hasHistory = obj.tags.some((t) => t.namespace === 'history' && (t.tag === null || t.tag === '$self'))
|
|
221
|
+
if (!hasHistory) {
|
|
222
|
+
diagnostics.push({
|
|
223
|
+
objectName: obj.objectName,
|
|
224
|
+
sourceFile: output.sourceFile,
|
|
225
|
+
message: `Table '${obj.objectName}' has no @history tag`,
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return diagnostics
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
],
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export default plugin
|