@shetty4l/core 0.1.33 → 0.1.34

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shetty4l/core",
3
- "version": "0.1.33",
3
+ "version": "0.1.34",
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,222 @@
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
+ * Load a state object by key.
39
+ *
40
+ * If the row doesn't exist, creates one with default values.
41
+ * Returns a proxy that auto-saves on property changes.
42
+ *
43
+ * @param Cls - The @Persisted class constructor
44
+ * @param key - Unique key for this state instance
45
+ * @returns Proxied instance that auto-saves changes
46
+ * @throws Error if class is not decorated with @Persisted
47
+ */
48
+ load<T extends object>(Cls: new () => T, key: string): T {
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
59
+ ensureTable(this.db, meta);
60
+ migrateAdditive(this.db, meta);
61
+
62
+ // Create instance to get default values
63
+ const instance = new Cls();
64
+
65
+ // Try to load existing row
66
+ const row = this.selectRow(meta, key);
67
+
68
+ if (row) {
69
+ // Populate instance from row
70
+ for (const field of meta.fields.values()) {
71
+ const rawValue = row[field.column];
72
+ // If column is NULL (e.g., newly added via migration), keep default value
73
+ if (rawValue !== null && rawValue !== undefined) {
74
+ const value = deserializeValue(rawValue, field.type);
75
+ (instance as Record<string, unknown>)[field.property] = value;
76
+ }
77
+ }
78
+ } else {
79
+ // Insert default values
80
+ this.insertRow(meta, key, instance);
81
+ }
82
+
83
+ // Return proxy for auto-save
84
+ return this.createProxy(instance, meta, key);
85
+ }
86
+
87
+ /**
88
+ * Flush all pending saves immediately.
89
+ *
90
+ * Call this before shutdown to ensure all changes are persisted.
91
+ */
92
+ async flush(): Promise<void> {
93
+ // Cancel all timers
94
+ for (const timer of this.timers.values()) {
95
+ clearTimeout(timer);
96
+ }
97
+ this.timers.clear();
98
+
99
+ // Execute all pending saves
100
+ for (const save of this.pendingSaves.values()) {
101
+ save();
102
+ }
103
+ this.pendingSaves.clear();
104
+ }
105
+
106
+ private selectRow(
107
+ meta: ClassMeta,
108
+ key: string,
109
+ ): Record<string, unknown> | null {
110
+ const stmt = this.db.prepare(`SELECT * FROM ${meta.table} WHERE key = ?`);
111
+ return stmt.get(key) as Record<string, unknown> | null;
112
+ }
113
+
114
+ private insertRow<T extends object>(
115
+ meta: ClassMeta,
116
+ key: string,
117
+ instance: T,
118
+ ): void {
119
+ const columns = ["key", "updated_at"];
120
+ const placeholders = ["?", "datetime('now')"];
121
+ const values: SQLQueryBindings[] = [key];
122
+
123
+ for (const field of meta.fields.values()) {
124
+ columns.push(field.column);
125
+ placeholders.push("?");
126
+ const value = (instance as Record<string, unknown>)[field.property];
127
+ values.push(serializeValue(value, field.type));
128
+ }
129
+
130
+ const sql = `INSERT INTO ${meta.table} (${columns.join(", ")}) VALUES (${placeholders.join(", ")})`;
131
+ this.db.prepare(sql).run(...values);
132
+ }
133
+
134
+ private saveRow<T extends object>(
135
+ meta: ClassMeta,
136
+ key: string,
137
+ instance: T,
138
+ ): void {
139
+ const setClauses = ["updated_at = datetime('now')"];
140
+ const values: SQLQueryBindings[] = [];
141
+
142
+ for (const field of meta.fields.values()) {
143
+ setClauses.push(`${field.column} = ?`);
144
+ const value = (instance as Record<string, unknown>)[field.property];
145
+ values.push(serializeValue(value, field.type));
146
+ }
147
+
148
+ values.push(key);
149
+ const sql = `UPDATE ${meta.table} SET ${setClauses.join(", ")} WHERE key = ?`;
150
+ this.db.prepare(sql).run(...values);
151
+ }
152
+
153
+ private createProxy<T extends object>(
154
+ instance: T,
155
+ meta: ClassMeta,
156
+ key: string,
157
+ ): T {
158
+ const saveKey = `${meta.table}:${key}`;
159
+ const scheduleSave = this.scheduleSave.bind(this);
160
+ const saveRow = this.saveRow.bind(this);
161
+
162
+ return new Proxy(instance, {
163
+ set(target, prop, value): boolean {
164
+ // Set the value
165
+ (target as Record<string | symbol, unknown>)[prop] = value;
166
+
167
+ // Only schedule save for @Field properties
168
+ const propStr = String(prop);
169
+ if (!meta.fields.has(propStr)) {
170
+ return true;
171
+ }
172
+
173
+ // Schedule debounced save
174
+ scheduleSave(saveKey, () => {
175
+ saveRow(meta, key, target);
176
+ });
177
+
178
+ return true;
179
+ },
180
+
181
+ get(target, prop, receiver): unknown {
182
+ const value = Reflect.get(target, prop, receiver);
183
+ // Bind methods to the target
184
+ if (typeof value === "function") {
185
+ return value.bind(target);
186
+ }
187
+ return value;
188
+ },
189
+
190
+ ownKeys(target): (string | symbol)[] {
191
+ return Reflect.ownKeys(target);
192
+ },
193
+
194
+ getOwnPropertyDescriptor(target, prop): PropertyDescriptor | undefined {
195
+ return Object.getOwnPropertyDescriptor(target, prop);
196
+ },
197
+ });
198
+ }
199
+
200
+ private scheduleSave(saveKey: string, saveFn: () => void): void {
201
+ // Cancel existing timer
202
+ const existingTimer = this.timers.get(saveKey);
203
+ if (existingTimer) {
204
+ clearTimeout(existingTimer);
205
+ }
206
+
207
+ // Store the save function
208
+ this.pendingSaves.set(saveKey, saveFn);
209
+
210
+ // Schedule new timer
211
+ const timer = setTimeout(() => {
212
+ this.timers.delete(saveKey);
213
+ const fn = this.pendingSaves.get(saveKey);
214
+ if (fn) {
215
+ fn();
216
+ this.pendingSaves.delete(saveKey);
217
+ }
218
+ }, DEBOUNCE_MS);
219
+
220
+ this.timers.set(saveKey, timer);
221
+ }
222
+ }
@@ -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>();