@shetty4l/core 0.1.33 → 0.1.35
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 +4 -2
- package/src/index.ts +1 -0
- package/src/state/decorators.ts +111 -0
- package/src/state/index.ts +31 -0
- package/src/state/loader.ts +252 -0
- package/src/state/schema.ts +69 -0
- package/src/state/serialization.ts +103 -0
- package/src/state/types.ts +30 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shetty4l/core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.35",
|
|
4
4
|
"description": "Shared infrastructure primitives for Bun/TypeScript services",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"./daemon": "./src/daemon.ts",
|
|
19
19
|
"./db": "./src/db.ts",
|
|
20
20
|
"./http": "./src/http.ts",
|
|
21
|
-
"./log": "./src/log.ts"
|
|
21
|
+
"./log": "./src/log.ts",
|
|
22
|
+
"./state": "./src/state/index.ts"
|
|
22
23
|
},
|
|
23
24
|
"files": [
|
|
24
25
|
"src/",
|
|
@@ -44,6 +45,7 @@
|
|
|
44
45
|
"devDependencies": {
|
|
45
46
|
"@biomejs/biome": "^2.3.13",
|
|
46
47
|
"@types/bun": "latest",
|
|
48
|
+
"fast-check": "^4.5.3",
|
|
47
49
|
"husky": "^9.0.0",
|
|
48
50
|
"oxlint": "^1.48.0",
|
|
49
51
|
"typescript": "^5.0.0"
|
package/src/index.ts
CHANGED
|
@@ -19,5 +19,6 @@ export type { Err, Ok, Port, Result } from "./result";
|
|
|
19
19
|
export { err, ok } from "./result";
|
|
20
20
|
export type { ShutdownOpts } from "./signals";
|
|
21
21
|
export { onShutdown } from "./signals";
|
|
22
|
+
export * as state from "./state";
|
|
22
23
|
// Universal primitives — exported directly
|
|
23
24
|
export { readVersion } from "./version";
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TC39 decorators for state persistence.
|
|
3
|
+
*
|
|
4
|
+
* Uses explicit type specification for all fields to ensure correct
|
|
5
|
+
* serialization without relying on runtime type inference.
|
|
6
|
+
*
|
|
7
|
+
* Note: Bun's TC39 decorator implementation differs from the spec:
|
|
8
|
+
* - Field decorator context is the field name as a string (not an object)
|
|
9
|
+
* - Class decorator context is undefined (not an object)
|
|
10
|
+
* - Field decorators run before class decorator (allows global accumulation)
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* @Persisted('my_state')
|
|
15
|
+
* class MyState {
|
|
16
|
+
* @Field('string') name: string = '';
|
|
17
|
+
* @Field('date') createdAt: Date | null = null;
|
|
18
|
+
* @Field('number') count: number = 0;
|
|
19
|
+
* @Field('boolean') enabled: boolean = true;
|
|
20
|
+
* }
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { classMeta, type FieldMeta, type FieldType } from "./types";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Convert camelCase to snake_case.
|
|
28
|
+
*/
|
|
29
|
+
function toSnakeCase(str: string): string {
|
|
30
|
+
return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Options for the @Field decorator. */
|
|
34
|
+
export interface FieldOptions {
|
|
35
|
+
/** Custom column name. Defaults to snake_case of property name. */
|
|
36
|
+
column?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type PendingFieldDef = { column: string; type: FieldType };
|
|
40
|
+
type PendingFieldsMap = Map<string, PendingFieldDef>;
|
|
41
|
+
|
|
42
|
+
// Global accumulator for pending field definitions.
|
|
43
|
+
// In Bun's TC39 implementation, @Field decorators run synchronously
|
|
44
|
+
// before the @Persisted decorator for the same class.
|
|
45
|
+
let globalPendingFields: PendingFieldsMap | null = null;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Mark a class as persisted to a SQLite table.
|
|
49
|
+
*
|
|
50
|
+
* @param table - SQLite table name
|
|
51
|
+
* @throws Error if class extends another @Persisted class
|
|
52
|
+
*/
|
|
53
|
+
export function Persisted(table: string) {
|
|
54
|
+
// Bun's TC39 decorator: context is undefined, not ClassDecoratorContext
|
|
55
|
+
return function <T extends new (...args: unknown[]) => object>(
|
|
56
|
+
target: T,
|
|
57
|
+
_context: unknown,
|
|
58
|
+
): T {
|
|
59
|
+
// Check prototype chain for existing @Persisted class
|
|
60
|
+
let proto = Object.getPrototypeOf(target);
|
|
61
|
+
while (proto && proto !== Function.prototype) {
|
|
62
|
+
if (classMeta.has(proto)) {
|
|
63
|
+
const parentMeta = classMeta.get(proto)!;
|
|
64
|
+
throw new Error(
|
|
65
|
+
`@Persisted class "${target.name}" cannot extend @Persisted class "${proto.name}" (table: "${parentMeta.table}"). ` +
|
|
66
|
+
`State classes do not support inheritance.`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
proto = Object.getPrototypeOf(proto);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Initialize metadata for this class
|
|
73
|
+
const meta = { table, fields: new Map<string, FieldMeta>() };
|
|
74
|
+
|
|
75
|
+
// Consume pending fields accumulated by @Field decorators
|
|
76
|
+
if (globalPendingFields) {
|
|
77
|
+
for (const [property, def] of globalPendingFields) {
|
|
78
|
+
meta.fields.set(property, {
|
|
79
|
+
property,
|
|
80
|
+
column: def.column,
|
|
81
|
+
type: def.type,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
globalPendingFields = null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
classMeta.set(target, meta);
|
|
88
|
+
return target;
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Mark a property as a persisted field.
|
|
94
|
+
*
|
|
95
|
+
* @param type - The field type ('string' | 'number' | 'boolean' | 'date')
|
|
96
|
+
* @param options - Optional field configuration (column name override)
|
|
97
|
+
*/
|
|
98
|
+
export function Field(type: FieldType, options?: FieldOptions) {
|
|
99
|
+
// Bun's TC39 decorator: context is the field name as string, not ClassFieldDecoratorContext
|
|
100
|
+
return function (_target: undefined, context: unknown): void {
|
|
101
|
+
// In Bun, context is the field name as a string
|
|
102
|
+
const property = typeof context === "string" ? context : String(context);
|
|
103
|
+
const column = options?.column ?? toSnakeCase(property);
|
|
104
|
+
|
|
105
|
+
// Accumulate field definitions
|
|
106
|
+
if (!globalPendingFields) {
|
|
107
|
+
globalPendingFields = new Map();
|
|
108
|
+
}
|
|
109
|
+
globalPendingFields.set(property, { column, type });
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State persistence module.
|
|
3
|
+
*
|
|
4
|
+
* Provides decorator-based state persistence with auto-save to SQLite.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```ts
|
|
8
|
+
* import { Persisted, Field, StateLoader } from '@shetty4l/core/state';
|
|
9
|
+
*
|
|
10
|
+
* @Persisted('my_state')
|
|
11
|
+
* class MyState {
|
|
12
|
+
* @Field() counter: number = 0;
|
|
13
|
+
* @Field({ type: 'date' }) lastUpdated: Date | null = null;
|
|
14
|
+
* }
|
|
15
|
+
*
|
|
16
|
+
* const loader = new StateLoader(db);
|
|
17
|
+
* const state = loader.load(MyState, 'my-key');
|
|
18
|
+
* state.counter += 1; // Auto-saves after 100ms
|
|
19
|
+
* await loader.flush(); // Force immediate save
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export type { FieldOptions } from "./decorators";
|
|
24
|
+
// Decorators
|
|
25
|
+
export { Field, Persisted } from "./decorators";
|
|
26
|
+
|
|
27
|
+
// Loader
|
|
28
|
+
export { StateLoader } from "./loader";
|
|
29
|
+
|
|
30
|
+
// Types
|
|
31
|
+
export type { ClassMeta, FieldMeta, FieldType } from "./types";
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StateLoader: Load and auto-persist state objects.
|
|
3
|
+
*
|
|
4
|
+
* Provides a proxy-based approach to automatically save state changes
|
|
5
|
+
* to SQLite with debounced writes.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Database, SQLQueryBindings } from "bun:sqlite";
|
|
9
|
+
import { ensureTable, migrateAdditive } from "./schema";
|
|
10
|
+
import { deserializeValue, serializeValue } from "./serialization";
|
|
11
|
+
import type { ClassMeta } from "./types";
|
|
12
|
+
import { classMeta } from "./types";
|
|
13
|
+
|
|
14
|
+
/** Debounce delay in milliseconds. */
|
|
15
|
+
const DEBOUNCE_MS = 100;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* StateLoader manages loading and persisting state objects.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```ts
|
|
22
|
+
* const loader = new StateLoader(db);
|
|
23
|
+
* const state = await loader.load(MyState, 'my-key');
|
|
24
|
+
* state.counter += 1; // Auto-saves after 100ms
|
|
25
|
+
* await loader.flush(); // Force immediate save
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export class StateLoader {
|
|
29
|
+
private db: Database;
|
|
30
|
+
private pendingSaves = new Map<string, () => void>();
|
|
31
|
+
private timers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
32
|
+
|
|
33
|
+
constructor(db: Database) {
|
|
34
|
+
this.db = db;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Check if a state row exists for the given key.
|
|
39
|
+
*
|
|
40
|
+
* Unlike `load()`, this does NOT create a row if it doesn't exist.
|
|
41
|
+
* Ensures table exists and migrates if needed (consistent with load()).
|
|
42
|
+
*
|
|
43
|
+
* @param Cls - The @Persisted class constructor
|
|
44
|
+
* @param key - Unique key to check
|
|
45
|
+
* @returns `true` if row exists, `false` otherwise
|
|
46
|
+
* @throws Error if class is not decorated with @Persisted
|
|
47
|
+
*/
|
|
48
|
+
exists<T extends object>(Cls: new () => T, key: string): boolean {
|
|
49
|
+
// Get metadata
|
|
50
|
+
const meta = classMeta.get(Cls);
|
|
51
|
+
if (!meta || !meta.table) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`Class "${Cls.name}" is not decorated with @Persisted. ` +
|
|
54
|
+
`Add @Persisted('table_name') to the class.`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Ensure table exists and migrate if needed (consistent with load())
|
|
59
|
+
ensureTable(this.db, meta);
|
|
60
|
+
migrateAdditive(this.db, meta);
|
|
61
|
+
|
|
62
|
+
// Check if row exists
|
|
63
|
+
const row = this.selectRow(meta, key);
|
|
64
|
+
return row !== null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Load a state object by key.
|
|
69
|
+
*
|
|
70
|
+
* If the row doesn't exist, creates one with default values.
|
|
71
|
+
* Returns a proxy that auto-saves on property changes.
|
|
72
|
+
*
|
|
73
|
+
* @param Cls - The @Persisted class constructor
|
|
74
|
+
* @param key - Unique key for this state instance
|
|
75
|
+
* @returns Proxied instance that auto-saves changes
|
|
76
|
+
* @throws Error if class is not decorated with @Persisted
|
|
77
|
+
*/
|
|
78
|
+
load<T extends object>(Cls: new () => T, key: string): T {
|
|
79
|
+
// Get metadata
|
|
80
|
+
const meta = classMeta.get(Cls);
|
|
81
|
+
if (!meta || !meta.table) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`Class "${Cls.name}" is not decorated with @Persisted. ` +
|
|
84
|
+
`Add @Persisted('table_name') to the class.`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Ensure table exists and migrate if needed
|
|
89
|
+
ensureTable(this.db, meta);
|
|
90
|
+
migrateAdditive(this.db, meta);
|
|
91
|
+
|
|
92
|
+
// Create instance to get default values
|
|
93
|
+
const instance = new Cls();
|
|
94
|
+
|
|
95
|
+
// Try to load existing row
|
|
96
|
+
const row = this.selectRow(meta, key);
|
|
97
|
+
|
|
98
|
+
if (row) {
|
|
99
|
+
// Populate instance from row
|
|
100
|
+
for (const field of meta.fields.values()) {
|
|
101
|
+
const rawValue = row[field.column];
|
|
102
|
+
// If column is NULL (e.g., newly added via migration), keep default value
|
|
103
|
+
if (rawValue !== null && rawValue !== undefined) {
|
|
104
|
+
const value = deserializeValue(rawValue, field.type);
|
|
105
|
+
(instance as Record<string, unknown>)[field.property] = value;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
} else {
|
|
109
|
+
// Insert default values
|
|
110
|
+
this.insertRow(meta, key, instance);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Return proxy for auto-save
|
|
114
|
+
return this.createProxy(instance, meta, key);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Flush all pending saves immediately.
|
|
119
|
+
*
|
|
120
|
+
* Call this before shutdown to ensure all changes are persisted.
|
|
121
|
+
*/
|
|
122
|
+
async flush(): Promise<void> {
|
|
123
|
+
// Cancel all timers
|
|
124
|
+
for (const timer of this.timers.values()) {
|
|
125
|
+
clearTimeout(timer);
|
|
126
|
+
}
|
|
127
|
+
this.timers.clear();
|
|
128
|
+
|
|
129
|
+
// Execute all pending saves
|
|
130
|
+
for (const save of this.pendingSaves.values()) {
|
|
131
|
+
save();
|
|
132
|
+
}
|
|
133
|
+
this.pendingSaves.clear();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private selectRow(
|
|
137
|
+
meta: ClassMeta,
|
|
138
|
+
key: string,
|
|
139
|
+
): Record<string, unknown> | null {
|
|
140
|
+
const stmt = this.db.prepare(`SELECT * FROM ${meta.table} WHERE key = ?`);
|
|
141
|
+
return stmt.get(key) as Record<string, unknown> | null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private insertRow<T extends object>(
|
|
145
|
+
meta: ClassMeta,
|
|
146
|
+
key: string,
|
|
147
|
+
instance: T,
|
|
148
|
+
): void {
|
|
149
|
+
const columns = ["key", "updated_at"];
|
|
150
|
+
const placeholders = ["?", "datetime('now')"];
|
|
151
|
+
const values: SQLQueryBindings[] = [key];
|
|
152
|
+
|
|
153
|
+
for (const field of meta.fields.values()) {
|
|
154
|
+
columns.push(field.column);
|
|
155
|
+
placeholders.push("?");
|
|
156
|
+
const value = (instance as Record<string, unknown>)[field.property];
|
|
157
|
+
values.push(serializeValue(value, field.type));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const sql = `INSERT INTO ${meta.table} (${columns.join(", ")}) VALUES (${placeholders.join(", ")})`;
|
|
161
|
+
this.db.prepare(sql).run(...values);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private saveRow<T extends object>(
|
|
165
|
+
meta: ClassMeta,
|
|
166
|
+
key: string,
|
|
167
|
+
instance: T,
|
|
168
|
+
): void {
|
|
169
|
+
const setClauses = ["updated_at = datetime('now')"];
|
|
170
|
+
const values: SQLQueryBindings[] = [];
|
|
171
|
+
|
|
172
|
+
for (const field of meta.fields.values()) {
|
|
173
|
+
setClauses.push(`${field.column} = ?`);
|
|
174
|
+
const value = (instance as Record<string, unknown>)[field.property];
|
|
175
|
+
values.push(serializeValue(value, field.type));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
values.push(key);
|
|
179
|
+
const sql = `UPDATE ${meta.table} SET ${setClauses.join(", ")} WHERE key = ?`;
|
|
180
|
+
this.db.prepare(sql).run(...values);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private createProxy<T extends object>(
|
|
184
|
+
instance: T,
|
|
185
|
+
meta: ClassMeta,
|
|
186
|
+
key: string,
|
|
187
|
+
): T {
|
|
188
|
+
const saveKey = `${meta.table}:${key}`;
|
|
189
|
+
const scheduleSave = this.scheduleSave.bind(this);
|
|
190
|
+
const saveRow = this.saveRow.bind(this);
|
|
191
|
+
|
|
192
|
+
return new Proxy(instance, {
|
|
193
|
+
set(target, prop, value): boolean {
|
|
194
|
+
// Set the value
|
|
195
|
+
(target as Record<string | symbol, unknown>)[prop] = value;
|
|
196
|
+
|
|
197
|
+
// Only schedule save for @Field properties
|
|
198
|
+
const propStr = String(prop);
|
|
199
|
+
if (!meta.fields.has(propStr)) {
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Schedule debounced save
|
|
204
|
+
scheduleSave(saveKey, () => {
|
|
205
|
+
saveRow(meta, key, target);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
return true;
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
get(target, prop, receiver): unknown {
|
|
212
|
+
const value = Reflect.get(target, prop, receiver);
|
|
213
|
+
// Bind methods to the target
|
|
214
|
+
if (typeof value === "function") {
|
|
215
|
+
return value.bind(target);
|
|
216
|
+
}
|
|
217
|
+
return value;
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
ownKeys(target): (string | symbol)[] {
|
|
221
|
+
return Reflect.ownKeys(target);
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
getOwnPropertyDescriptor(target, prop): PropertyDescriptor | undefined {
|
|
225
|
+
return Object.getOwnPropertyDescriptor(target, prop);
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private scheduleSave(saveKey: string, saveFn: () => void): void {
|
|
231
|
+
// Cancel existing timer
|
|
232
|
+
const existingTimer = this.timers.get(saveKey);
|
|
233
|
+
if (existingTimer) {
|
|
234
|
+
clearTimeout(existingTimer);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Store the save function
|
|
238
|
+
this.pendingSaves.set(saveKey, saveFn);
|
|
239
|
+
|
|
240
|
+
// Schedule new timer
|
|
241
|
+
const timer = setTimeout(() => {
|
|
242
|
+
this.timers.delete(saveKey);
|
|
243
|
+
const fn = this.pendingSaves.get(saveKey);
|
|
244
|
+
if (fn) {
|
|
245
|
+
fn();
|
|
246
|
+
this.pendingSaves.delete(saveKey);
|
|
247
|
+
}
|
|
248
|
+
}, DEBOUNCE_MS);
|
|
249
|
+
|
|
250
|
+
this.timers.set(saveKey, timer);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema management for SQLite persistence.
|
|
3
|
+
*
|
|
4
|
+
* Handles table creation and additive schema migrations.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Database } from "bun:sqlite";
|
|
8
|
+
import { sqliteType } from "./serialization";
|
|
9
|
+
import type { ClassMeta } from "./types";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Ensure a table exists for the given class metadata.
|
|
13
|
+
*
|
|
14
|
+
* Creates a table with:
|
|
15
|
+
* - `key` TEXT PRIMARY KEY
|
|
16
|
+
* - One column per @Field (snake_case names)
|
|
17
|
+
* - `updated_at` TEXT for tracking modifications
|
|
18
|
+
*
|
|
19
|
+
* @param db - SQLite database instance
|
|
20
|
+
* @param meta - Class metadata from @Persisted/@Field decorators
|
|
21
|
+
*/
|
|
22
|
+
export function ensureTable(db: Database, meta: ClassMeta): void {
|
|
23
|
+
const columns: string[] = ["key TEXT PRIMARY KEY"];
|
|
24
|
+
|
|
25
|
+
for (const field of meta.fields.values()) {
|
|
26
|
+
const sqlType = sqliteType(field.type);
|
|
27
|
+
columns.push(`${field.column} ${sqlType}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
columns.push("updated_at TEXT");
|
|
31
|
+
|
|
32
|
+
const sql = `CREATE TABLE IF NOT EXISTS ${meta.table} (${columns.join(", ")})`;
|
|
33
|
+
db.exec(sql);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Perform additive migration: add any missing columns.
|
|
38
|
+
*
|
|
39
|
+
* This function:
|
|
40
|
+
* - Reads existing columns via PRAGMA table_info
|
|
41
|
+
* - Adds columns for any @Field not yet in the table
|
|
42
|
+
* - Never drops or modifies existing columns
|
|
43
|
+
* - Is idempotent (safe to call multiple times)
|
|
44
|
+
*
|
|
45
|
+
* @param db - SQLite database instance
|
|
46
|
+
* @param meta - Class metadata from @Persisted/@Field decorators
|
|
47
|
+
*/
|
|
48
|
+
export function migrateAdditive(db: Database, meta: ClassMeta): void {
|
|
49
|
+
// Get existing columns
|
|
50
|
+
const info = db.prepare(`PRAGMA table_info(${meta.table})`).all() as {
|
|
51
|
+
name: string;
|
|
52
|
+
}[];
|
|
53
|
+
const existingColumns = new Set(info.map((row) => row.name));
|
|
54
|
+
|
|
55
|
+
// Add missing columns
|
|
56
|
+
for (const field of meta.fields.values()) {
|
|
57
|
+
if (!existingColumns.has(field.column)) {
|
|
58
|
+
const sqlType = sqliteType(field.type);
|
|
59
|
+
db.exec(
|
|
60
|
+
`ALTER TABLE ${meta.table} ADD COLUMN ${field.column} ${sqlType}`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Ensure updated_at exists
|
|
66
|
+
if (!existingColumns.has("updated_at")) {
|
|
67
|
+
db.exec(`ALTER TABLE ${meta.table} ADD COLUMN updated_at TEXT`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serialization utilities for SQLite persistence.
|
|
3
|
+
*
|
|
4
|
+
* Handles conversion between JavaScript types and SQLite-compatible values.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { FieldType } from "./types";
|
|
8
|
+
|
|
9
|
+
/** SQLite column type. */
|
|
10
|
+
export type SqliteType = "TEXT" | "REAL" | "INTEGER";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Serialize a JavaScript value for SQLite storage.
|
|
14
|
+
*
|
|
15
|
+
* @param value - The value to serialize
|
|
16
|
+
* @param type - The field type
|
|
17
|
+
* @returns SQLite-compatible value
|
|
18
|
+
* @throws Error if value is NaN or Infinity
|
|
19
|
+
*/
|
|
20
|
+
export function serializeValue(
|
|
21
|
+
value: unknown,
|
|
22
|
+
type: FieldType,
|
|
23
|
+
): string | number | null {
|
|
24
|
+
if (value === null || value === undefined) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
switch (type) {
|
|
29
|
+
case "string":
|
|
30
|
+
return String(value);
|
|
31
|
+
|
|
32
|
+
case "number": {
|
|
33
|
+
const num = value as number;
|
|
34
|
+
if (Number.isNaN(num)) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
"Cannot serialize NaN to SQLite. Ensure the value is a valid number.",
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
if (!Number.isFinite(num)) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
"Cannot serialize Infinity to SQLite. Ensure the value is a finite number.",
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
return num;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
case "boolean":
|
|
48
|
+
return value ? 1 : 0;
|
|
49
|
+
|
|
50
|
+
case "date":
|
|
51
|
+
return (value as Date).toISOString();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Deserialize a SQLite value to a JavaScript type.
|
|
57
|
+
*
|
|
58
|
+
* @param value - The SQLite value
|
|
59
|
+
* @param type - The field type
|
|
60
|
+
* @returns Deserialized JavaScript value
|
|
61
|
+
*/
|
|
62
|
+
export function deserializeValue(
|
|
63
|
+
value: unknown,
|
|
64
|
+
type: FieldType,
|
|
65
|
+
): string | number | boolean | Date | null {
|
|
66
|
+
if (value === null || value === undefined) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
switch (type) {
|
|
71
|
+
case "string":
|
|
72
|
+
return String(value);
|
|
73
|
+
|
|
74
|
+
case "number":
|
|
75
|
+
return Number(value);
|
|
76
|
+
|
|
77
|
+
case "boolean":
|
|
78
|
+
return value === 1 || value === true;
|
|
79
|
+
|
|
80
|
+
case "date":
|
|
81
|
+
return new Date(value as string);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Get the SQLite column type for a field type.
|
|
87
|
+
*
|
|
88
|
+
* @param type - The field type
|
|
89
|
+
* @returns SQLite column type
|
|
90
|
+
*/
|
|
91
|
+
export function sqliteType(type: FieldType): SqliteType {
|
|
92
|
+
switch (type) {
|
|
93
|
+
case "string":
|
|
94
|
+
case "date":
|
|
95
|
+
return "TEXT";
|
|
96
|
+
|
|
97
|
+
case "number":
|
|
98
|
+
return "REAL";
|
|
99
|
+
|
|
100
|
+
case "boolean":
|
|
101
|
+
return "INTEGER";
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type definitions for the state persistence system.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Supported field types for persistence. */
|
|
6
|
+
export type FieldType = "string" | "number" | "boolean" | "date";
|
|
7
|
+
|
|
8
|
+
/** Metadata for a single persisted field. */
|
|
9
|
+
export interface FieldMeta {
|
|
10
|
+
/** Property name on the class. */
|
|
11
|
+
property: string;
|
|
12
|
+
/** SQLite column name (snake_case). */
|
|
13
|
+
column: string;
|
|
14
|
+
/** Field type for serialization. */
|
|
15
|
+
type: FieldType;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Metadata for a persisted class. */
|
|
19
|
+
export interface ClassMeta {
|
|
20
|
+
/** SQLite table name. */
|
|
21
|
+
table: string;
|
|
22
|
+
/** Map of property name to field metadata. */
|
|
23
|
+
fields: Map<string, FieldMeta>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* WeakMap storing class metadata.
|
|
28
|
+
* Keyed by constructor function, holds ClassMeta.
|
|
29
|
+
*/
|
|
30
|
+
export const classMeta = new WeakMap<object, ClassMeta>();
|