turbine-orm 0.36.1 → 0.37.0

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.
@@ -0,0 +1,306 @@
1
+ /**
2
+ * turbine-orm CLI: Studio demo mode (`turbine studio --demo`)
3
+ *
4
+ * Boots Studio with NO database and NO DATABASE_URL: a baked-in, seeded sample
5
+ * dataset served from an in-memory engine. It is the "feel the product in 10
6
+ * seconds" experience: read mode, PII redaction, and the single-row write flow,
7
+ * all safely fake.
8
+ *
9
+ * The store is backed by Turbine's OWN SQLite engine over `node:sqlite`'s
10
+ * `:memory:` database (a built-in on Node >= 22.5, zero new dependency). Because
11
+ * `:memory:` is per-handle, the store dies with the process and every launch
12
+ * starts pristine: writes genuinely apply (edits stick, a refresh shows them)
13
+ * but nothing is ever persisted anywhere.
14
+ *
15
+ * This module lives under `src/cli/` (coverage-excluded, never imported by
16
+ * library code) and reuses `SqlitePool` + `sqliteDialect` from `../sqlite.js`;
17
+ * it never writes its own SQL evaluator.
18
+ */
19
+ import { createRequire } from 'node:module';
20
+ import { SqlitePool, sqliteDialect } from '../sqlite.js';
21
+ /**
22
+ * Load `node:sqlite`'s `DatabaseSync` constructor, throwing a clear,
23
+ * demo-specific message on older Node. Kept lazy (called only when a demo
24
+ * context is actually created) so `import`ing this module never crashes the CLI
25
+ * on Node < 22.5.
26
+ */
27
+ function loadDatabaseSync() {
28
+ let ctor;
29
+ try {
30
+ const req = createRequire(process.cwd());
31
+ ctor = req('node:sqlite').DatabaseSync;
32
+ }
33
+ catch {
34
+ ctor = undefined;
35
+ }
36
+ if (typeof ctor !== 'function') {
37
+ throw new Error('studio --demo needs Node 22.5+ (uses the built-in node:sqlite engine)');
38
+ }
39
+ return ctor;
40
+ }
41
+ /** Map a demo column's Postgres-flavored type to a TypeScript type string. */
42
+ function demoTsType(pgType, nullable) {
43
+ let base;
44
+ if (/int|serial/i.test(pgType))
45
+ base = 'number';
46
+ else if (/bool/i.test(pgType))
47
+ base = 'boolean';
48
+ else if (/timestamp|date/i.test(pgType))
49
+ base = 'Date';
50
+ else
51
+ base = 'string';
52
+ return nullable ? `${base} | null` : base;
53
+ }
54
+ function demoColumn(spec) {
55
+ const nullable = spec.nullable === true;
56
+ return {
57
+ name: spec.name,
58
+ field: spec.field,
59
+ dialectType: spec.pgType,
60
+ pgType: spec.pgType,
61
+ tsType: demoTsType(spec.pgType, nullable),
62
+ nullable,
63
+ hasDefault: spec.hasDefault ?? spec.isGenerated ?? false,
64
+ isGenerated: spec.isGenerated === true,
65
+ pii: spec.pii === true,
66
+ isArray: false,
67
+ pgArrayType: 'text[]',
68
+ };
69
+ }
70
+ function demoTable(name, columnSpecs, relations = {}) {
71
+ const columns = columnSpecs.map(demoColumn);
72
+ const columnMap = {};
73
+ const reverseColumnMap = {};
74
+ const dateColumns = new Set();
75
+ const pgTypes = {};
76
+ const allColumns = [];
77
+ for (const col of columns) {
78
+ columnMap[col.field] = col.name;
79
+ reverseColumnMap[col.name] = col.field;
80
+ pgTypes[col.name] = col.pgType;
81
+ allColumns.push(col.name);
82
+ if (/timestamp|date/i.test(col.pgType))
83
+ dateColumns.add(col.name);
84
+ }
85
+ return {
86
+ name,
87
+ columns,
88
+ columnMap,
89
+ reverseColumnMap,
90
+ dateColumns,
91
+ dialectTypes: pgTypes,
92
+ pgTypes,
93
+ allColumns,
94
+ primaryKey: ['id'],
95
+ uniqueColumns: [['id']],
96
+ relations,
97
+ indexes: [],
98
+ };
99
+ }
100
+ /**
101
+ * The seeded sample schema. Four tables with realistic relations; `email` and
102
+ * `phone` are tagged `pii` so Studio's redaction path is exercised out of the
103
+ * box.
104
+ */
105
+ export const DEMO_SCHEMA = {
106
+ tables: {
107
+ users: demoTable('users', [
108
+ { name: 'id', field: 'id', pgType: 'int4', isGenerated: true },
109
+ { name: 'name', field: 'name', pgType: 'text' },
110
+ { name: 'email', field: 'email', pgType: 'text', pii: true },
111
+ { name: 'phone', field: 'phone', pgType: 'text', nullable: true, pii: true },
112
+ { name: 'role', field: 'role', pgType: 'text' },
113
+ { name: 'created_at', field: 'createdAt', pgType: 'timestamptz' },
114
+ ], {
115
+ posts: {
116
+ type: 'hasMany',
117
+ name: 'posts',
118
+ from: 'users',
119
+ to: 'posts',
120
+ foreignKey: 'user_id',
121
+ referenceKey: 'id',
122
+ },
123
+ }),
124
+ posts: demoTable('posts', [
125
+ { name: 'id', field: 'id', pgType: 'int4', isGenerated: true },
126
+ { name: 'user_id', field: 'userId', pgType: 'int4' },
127
+ { name: 'title', field: 'title', pgType: 'text' },
128
+ { name: 'body', field: 'body', pgType: 'text' },
129
+ { name: 'published', field: 'published', pgType: 'bool' },
130
+ { name: 'created_at', field: 'createdAt', pgType: 'timestamptz' },
131
+ ], {
132
+ comments: {
133
+ type: 'hasMany',
134
+ name: 'comments',
135
+ from: 'posts',
136
+ to: 'comments',
137
+ foreignKey: 'post_id',
138
+ referenceKey: 'id',
139
+ },
140
+ author: {
141
+ type: 'belongsTo',
142
+ name: 'author',
143
+ from: 'posts',
144
+ to: 'users',
145
+ foreignKey: 'user_id',
146
+ referenceKey: 'id',
147
+ },
148
+ }),
149
+ comments: demoTable('comments', [
150
+ { name: 'id', field: 'id', pgType: 'int4', isGenerated: true },
151
+ { name: 'post_id', field: 'postId', pgType: 'int4' },
152
+ { name: 'user_id', field: 'userId', pgType: 'int4' },
153
+ { name: 'body', field: 'body', pgType: 'text' },
154
+ { name: 'created_at', field: 'createdAt', pgType: 'timestamptz' },
155
+ ], {
156
+ post: {
157
+ type: 'belongsTo',
158
+ name: 'post',
159
+ from: 'comments',
160
+ to: 'posts',
161
+ foreignKey: 'post_id',
162
+ referenceKey: 'id',
163
+ },
164
+ user: {
165
+ type: 'belongsTo',
166
+ name: 'user',
167
+ from: 'comments',
168
+ to: 'users',
169
+ foreignKey: 'user_id',
170
+ referenceKey: 'id',
171
+ },
172
+ }),
173
+ orgs: demoTable('orgs', [
174
+ { name: 'id', field: 'id', pgType: 'int4', isGenerated: true },
175
+ { name: 'name', field: 'name', pgType: 'text' },
176
+ { name: 'plan', field: 'plan', pgType: 'text' },
177
+ ]),
178
+ },
179
+ enums: {},
180
+ };
181
+ // ---------------------------------------------------------------------------
182
+ // Deterministic seed data (hardcoded, no randomness)
183
+ // ---------------------------------------------------------------------------
184
+ /** A fixed base instant so every launch produces byte-identical timestamps. */
185
+ const SEED_EPOCH = Date.UTC(2024, 0, 1, 9, 0, 0);
186
+ const DAY_MS = 86_400_000;
187
+ /** ISO timestamp `n` days after the seed epoch (deterministic, no `Date.now()`). */
188
+ function seedTime(dayOffset) {
189
+ return new Date(SEED_EPOCH + dayOffset * DAY_MS).toISOString();
190
+ }
191
+ const USERS = [
192
+ { id: 1, name: 'Ada Lovelace', email: 'ada@example.com', phone: '+1-202-555-0101', role: 'admin' },
193
+ { id: 2, name: 'Grace Hopper', email: 'grace@example.com', phone: '+1-202-555-0102', role: 'admin' },
194
+ { id: 3, name: 'Alan Turing', email: 'alan@example.com', phone: '+1-202-555-0103', role: 'member' },
195
+ { id: 4, name: 'Katherine Johnson', email: 'katherine@example.com', phone: '+1-202-555-0104', role: 'member' },
196
+ { id: 5, name: 'Linus Torvalds', email: 'linus@example.com', phone: '+1-202-555-0105', role: 'member' },
197
+ { id: 6, name: 'Margaret Hamilton', email: 'margaret@example.com', phone: null, role: 'member' },
198
+ { id: 7, name: 'Dennis Ritchie', email: 'dennis@example.com', phone: '+1-202-555-0107', role: 'member' },
199
+ { id: 8, name: 'Barbara Liskov', email: 'barbara@example.com', phone: '+1-202-555-0108', role: 'viewer' },
200
+ ];
201
+ const ORGS = [
202
+ { id: 1, name: 'Analytical Engines', plan: 'pro' },
203
+ { id: 2, name: 'Compiler Collective', plan: 'team' },
204
+ { id: 3, name: 'Kernel Works', plan: 'free' },
205
+ ];
206
+ const POST_TOPICS = [
207
+ 'Notes on the Analytical Engine',
208
+ 'Debugging the first moth',
209
+ 'On computable numbers',
210
+ 'Orbital mechanics by hand',
211
+ 'Why monolithic kernels win',
212
+ 'The Apollo guidance software',
213
+ 'A tour of the C language',
214
+ 'Abstraction and specification',
215
+ 'Loop invariants in practice',
216
+ 'Sequential vs parallel search',
217
+ ];
218
+ /** 20 deterministic posts spread across the 8 users. */
219
+ const POSTS = Array.from({ length: 20 }, (_, i) => {
220
+ const id = i + 1;
221
+ const userId = (i % USERS.length) + 1;
222
+ return {
223
+ id,
224
+ userId,
225
+ title: `${POST_TOPICS[i % POST_TOPICS.length]} (part ${Math.floor(i / POST_TOPICS.length) + 1})`,
226
+ body: `A short sample body for post ${id}, written by user ${userId}. Everything here is fake demo data.`,
227
+ published: id % 4 !== 0,
228
+ createdAt: seedTime(id),
229
+ };
230
+ });
231
+ /** 40 deterministic comments (two per post), authored round-robin. */
232
+ const COMMENTS = Array.from({ length: 40 }, (_, i) => {
233
+ const id = i + 1;
234
+ const postId = (i % POSTS.length) + 1;
235
+ const userId = ((i * 3) % USERS.length) + 1;
236
+ return {
237
+ id,
238
+ postId,
239
+ userId,
240
+ body: `Comment ${id} on post ${postId}. Nicely done. This is seeded demo content.`,
241
+ createdAt: seedTime(20 + id),
242
+ };
243
+ });
244
+ // ---------------------------------------------------------------------------
245
+ // DDL + seeding
246
+ // ---------------------------------------------------------------------------
247
+ /** SQLite DDL for the demo tables. Types map cleanly onto SQLite affinities. */
248
+ const DEMO_DDL = `
249
+ CREATE TABLE orgs (
250
+ id INTEGER PRIMARY KEY,
251
+ name TEXT NOT NULL,
252
+ plan TEXT NOT NULL
253
+ );
254
+ CREATE TABLE users (
255
+ id INTEGER PRIMARY KEY,
256
+ name TEXT NOT NULL,
257
+ email TEXT NOT NULL,
258
+ phone TEXT,
259
+ role TEXT NOT NULL,
260
+ created_at TEXT NOT NULL
261
+ );
262
+ CREATE TABLE posts (
263
+ id INTEGER PRIMARY KEY,
264
+ user_id INTEGER NOT NULL REFERENCES users(id),
265
+ title TEXT NOT NULL,
266
+ body TEXT NOT NULL,
267
+ published INTEGER NOT NULL,
268
+ created_at TEXT NOT NULL
269
+ );
270
+ CREATE TABLE comments (
271
+ id INTEGER PRIMARY KEY,
272
+ post_id INTEGER NOT NULL REFERENCES posts(id),
273
+ user_id INTEGER NOT NULL REFERENCES users(id),
274
+ body TEXT NOT NULL,
275
+ created_at TEXT NOT NULL
276
+ );
277
+ `;
278
+ function seedDemoData(db) {
279
+ const insOrg = db.prepare('INSERT INTO orgs (id, name, plan) VALUES (?, ?, ?)');
280
+ for (const o of ORGS)
281
+ insOrg.run(o.id, o.name, o.plan);
282
+ const insUser = db.prepare('INSERT INTO users (id, name, email, phone, role, created_at) VALUES (?, ?, ?, ?, ?, ?)');
283
+ for (const u of USERS)
284
+ insUser.run(u.id, u.name, u.email, u.phone, u.role, seedTime(u.id));
285
+ const insPost = db.prepare('INSERT INTO posts (id, user_id, title, body, published, created_at) VALUES (?, ?, ?, ?, ?, ?)');
286
+ for (const p of POSTS)
287
+ insPost.run(p.id, p.userId, p.title, p.body, p.published ? 1 : 0, p.createdAt);
288
+ const insComment = db.prepare('INSERT INTO comments (id, post_id, user_id, body, created_at) VALUES (?, ?, ?, ?, ?)');
289
+ for (const c of COMMENTS)
290
+ insComment.run(c.id, c.postId, c.userId, c.body, c.createdAt);
291
+ }
292
+ /**
293
+ * Open a fresh, seeded in-memory demo store and return the pool + metadata +
294
+ * dialect Studio needs. Each call yields an independent, pristine database
295
+ * (`:memory:` is per-handle), so demo launches never share state.
296
+ *
297
+ * @throws Error on Node < 22.5 (no built-in `node:sqlite`).
298
+ */
299
+ export function createDemoContext() {
300
+ const DatabaseSyncCtor = loadDatabaseSync();
301
+ const db = new DatabaseSyncCtor(':memory:');
302
+ db.exec('PRAGMA foreign_keys = ON');
303
+ db.exec(DEMO_DDL);
304
+ seedDemoData(db);
305
+ return { pool: new SqlitePool(db), metadata: DEMO_SCHEMA, dialect: sqliteDialect };
306
+ }