nsa-sheets-db-builder 4.0.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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +188 -0
  3. package/bin/sheets-deployer.mjs +169 -0
  4. package/libs/alasql.js +15577 -0
  5. package/libs/common/gas_response_helper.ts +147 -0
  6. package/libs/common/gaserror.ts +101 -0
  7. package/libs/common/gaslogger.ts +172 -0
  8. package/libs/db_ddl.ts +316 -0
  9. package/libs/libraries.json +56 -0
  10. package/libs/spreadsheets_db.ts +4406 -0
  11. package/libs/triggers.ts +113 -0
  12. package/package.json +73 -0
  13. package/scripts/build.mjs +513 -0
  14. package/scripts/clean.mjs +31 -0
  15. package/scripts/create.mjs +94 -0
  16. package/scripts/ddl-handler.mjs +232 -0
  17. package/scripts/describe.mjs +38 -0
  18. package/scripts/drop.mjs +39 -0
  19. package/scripts/init.mjs +465 -0
  20. package/scripts/lib/utils.mjs +1019 -0
  21. package/scripts/login.mjs +102 -0
  22. package/scripts/provision.mjs +35 -0
  23. package/scripts/refresh-cache.mjs +34 -0
  24. package/scripts/set-key.mjs +48 -0
  25. package/scripts/setup-trigger.mjs +95 -0
  26. package/scripts/setup.mjs +677 -0
  27. package/scripts/show.mjs +37 -0
  28. package/scripts/sync.mjs +35 -0
  29. package/scripts/whoami.mjs +36 -0
  30. package/src/api/ddl-handler-entry.ts +136 -0
  31. package/src/api/ddl.ts +321 -0
  32. package/src/templates/.clasp.json.ejs +1 -0
  33. package/src/templates/appsscript.json.ejs +16 -0
  34. package/src/templates/config.ts.ejs +14 -0
  35. package/src/templates/ddl-handler-config.ts.ejs +3 -0
  36. package/src/templates/ddl-handler-main.ts.ejs +56 -0
  37. package/src/templates/main.ts.ejs +288 -0
  38. package/src/templates/rbac.ts.ejs +148 -0
  39. package/src/templates/views.ts.ejs +92 -0
  40. package/templates/blank.json +33 -0
  41. package/templates/blog-cms.json +507 -0
  42. package/templates/crm.json +360 -0
  43. package/templates/e-commerce.json +424 -0
  44. package/templates/inventory.json +307 -0
@@ -0,0 +1,465 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Initialize a new DB project from a template.
5
+ *
6
+ * Usage:
7
+ * node scripts/init.mjs --db <name>
8
+ * node scripts/init.mjs --db <name> --template <template>
9
+ * node scripts/init.mjs --db <name> --instances r,w
10
+ * node scripts/init.mjs --db <name> --auth simple --rbac
11
+ * node scripts/init.mjs --db <name> --views
12
+ *
13
+ * Interactive: prompts for template selection if --template not given.
14
+ * Non-interactive: use --template to skip the prompt.
15
+ *
16
+ * Flags:
17
+ * --instances Comma-separated instance types: rw, r, w (or read-write, read-only, write-only)
18
+ * Default: single read-write (rw) instance
19
+ * --auth Authentication mode: noAuth, simple, google
20
+ * noAuth — no authentication, audit "who" = "system"
21
+ * simple — user email + passphrase in request body
22
+ * google — Google Workspace SSO (Session.getActiveUser())
23
+ * Default: noAuth
24
+ * --rbac Enable role-based access control. Scaffolds rbac.json + users/roles tables.
25
+ * Requires --auth simple or --auth google.
26
+ * --views Enable SQL views support. Scaffolds views.json.
27
+ *
28
+ * Creates:
29
+ * dbs/<name>/project.json — all config (name, settings, authMode, features, environments)
30
+ * dbs/<name>/tables.json — table definitions (from template)
31
+ * dbs/<name>/libs/ — scaffolded library source files (.ts + .js)
32
+ * dbs/<name>/methods/ — custom method handlers (empty scaffold)
33
+ * dbs/<name>/rbac.json — RBAC config (if --rbac)
34
+ * dbs/<name>/views.json — SQL view definitions (if --views)
35
+ */
36
+
37
+ import fs from 'fs';
38
+ import path from 'path';
39
+ import readline from 'readline';
40
+ import {
41
+ parseArgs, PACKAGE_ROOT, DBS_DIR, COMMON_LIBS_DIR,
42
+ getEnvDefaults, resolveInstanceType,
43
+ loadRootConfig, saveRootConfig
44
+ } from './lib/utils.mjs';
45
+
46
+ const TEMPLATES_DIR = path.join(PACKAGE_ROOT, 'templates');
47
+
48
+ /**
49
+ * Library scaffold order — dependency-aware.
50
+ * Each entry: { name, file, source }
51
+ * source: 'local' = libs/ in package, 'common' = common/libs/
52
+ */
53
+ const LIB_SCAFFOLD_ORDER = [
54
+ { name: 'gaserror', file: 'gaserror.ts', source: 'common' },
55
+ { name: 'gaslogger', file: 'gaslogger.ts', source: 'common' },
56
+ { name: 'gas_response_helper', file: 'gas_response_helper.ts', source: 'common' },
57
+ { name: 'spreadsheets_db', file: 'spreadsheets_db.ts', source: 'local' },
58
+ { name: 'db_ddl', file: 'db_ddl.ts', source: 'local' },
59
+ { name: 'triggers', file: 'triggers.ts', source: 'local' },
60
+ { name: 'alasql', file: 'alasql.js', source: 'local', native: true }
61
+ ];
62
+
63
+ function loadTemplates() {
64
+ const files = fs.readdirSync(TEMPLATES_DIR).filter(f => f.endsWith('.json'));
65
+ const templates = [];
66
+ for (const file of files) {
67
+ const data = JSON.parse(fs.readFileSync(path.join(TEMPLATES_DIR, file), 'utf8'));
68
+ templates.push(data);
69
+ }
70
+ return templates;
71
+ }
72
+
73
+ function ask(rl, question) {
74
+ return new Promise(resolve => rl.question(question, resolve));
75
+ }
76
+
77
+ /**
78
+ * Scaffold library .ts files into dbs/<name>/libs/ with numeric prefixes.
79
+ * Files are copied from the package — user can edit them afterward.
80
+ */
81
+ function scaffoldLibs(libsDir) {
82
+ fs.mkdirSync(libsDir, { recursive: true });
83
+ let copied = 0;
84
+
85
+ for (let i = 0; i < LIB_SCAFFOLD_ORDER.length; i++) {
86
+ const lib = LIB_SCAFFOLD_ORDER[i];
87
+ const srcDir = lib.source === 'common'
88
+ ? COMMON_LIBS_DIR
89
+ : path.join(PACKAGE_ROOT, 'libs');
90
+ const srcPath = path.join(srcDir, lib.file);
91
+
92
+ if (!fs.existsSync(srcPath)) {
93
+ console.warn(` Warning: ${lib.file} not found at ${srcPath}, skipping.`);
94
+ continue;
95
+ }
96
+
97
+ const ext = lib.native ? path.extname(lib.file) : '.ts';
98
+ const destName = `${String(i).padStart(3, '0')}_${lib.name}${ext}`;
99
+ fs.copyFileSync(srcPath, path.join(libsDir, destName));
100
+ console.log(` ${destName}`);
101
+ copied++;
102
+ }
103
+
104
+ return copied;
105
+ }
106
+
107
+ /**
108
+ * Resolve instance types from --instances flag.
109
+ * Accepts short aliases: rw, r, w (or full names).
110
+ * Default: single read-write instance.
111
+ */
112
+ function resolveInstances(args) {
113
+ const instances = {};
114
+
115
+ if (args.instances && typeof args.instances === 'string') {
116
+ const inputs = args.instances.split(',').map(s => s.trim()).filter(Boolean);
117
+ const types = inputs.map(resolveInstanceType);
118
+ for (const type of types) {
119
+ const placeholder = types.length === 1 ? 'YOUR_SCRIPT_ID' : `YOUR_${type.toUpperCase().replace(/-/g, '_')}_SCRIPT_ID`;
120
+ instances[placeholder] = {
121
+ type,
122
+ deploymentId: ''
123
+ };
124
+ }
125
+ } else {
126
+ instances['YOUR_SCRIPT_ID'] = {
127
+ type: 'read-write',
128
+ deploymentId: ''
129
+ };
130
+ }
131
+
132
+ return instances;
133
+ }
134
+
135
+ function generateProjectJson(dbName, instances, envDefaults = {}, features = {}, authMode = 'noAuth') {
136
+ const project = {
137
+ name: dbName,
138
+ settings: {
139
+ loggingVerbosity: envDefaults.loggingVerbosity ?? 2
140
+ },
141
+ authMode,
142
+ environments: {
143
+ dev: {
144
+ driveFolderId: envDefaults.driveFolderId || '',
145
+ systemSpreadsheetId: '',
146
+ instances
147
+ },
148
+ prod: {
149
+ driveFolderId: '',
150
+ systemSpreadsheetId: '',
151
+ instances: {}
152
+ }
153
+ },
154
+ };
155
+
156
+ // Only add features block if any feature is enabled
157
+ if (features.rbac || features.views) {
158
+ project.features = {};
159
+ if (features.rbac) {
160
+ project.features.rbac = { enabled: true };
161
+ }
162
+ if (features.views) {
163
+ project.features.views = { enabled: true };
164
+ }
165
+ }
166
+
167
+ return JSON.stringify(project, null, 2) + '\n';
168
+ }
169
+
170
+ function generateTablesJson(tables) {
171
+ // Ensure every table has a spreadsheetId (default __new__ for creation)
172
+ const result = {};
173
+ for (const [name, table] of Object.entries(tables)) {
174
+ result[name] = {
175
+ spreadsheetId: '__new__',
176
+ ...table
177
+ };
178
+ }
179
+ return JSON.stringify(result, null, 2) + '\n';
180
+ }
181
+
182
+ /** RBAC system tables — injected when --rbac is used */
183
+ const RBAC_TABLES = {
184
+ users: {
185
+ spreadsheetId: '__new__',
186
+ sheetName: 'users',
187
+ schema: {
188
+ id: { type: 'string', primaryKey: true, required: true },
189
+ email: { type: 'string', required: true },
190
+ name: { type: 'string', required: true },
191
+ role_id: { type: 'string', foreignKey: 'roles.id' },
192
+ role: { type: 'string' },
193
+ active: { type: 'boolean', defaultValue: true },
194
+ __audit__: { type: 'object' },
195
+ __archived__: { type: 'boolean', defaultValue: false }
196
+ },
197
+ idGenerator: 'UUID',
198
+ deleteMode: 'soft'
199
+ },
200
+ roles: {
201
+ spreadsheetId: '__new__',
202
+ sheetName: 'roles',
203
+ schema: {
204
+ id: { type: 'string', primaryKey: true, required: true },
205
+ name: { type: 'string', required: true },
206
+ description: { type: 'string' },
207
+ __audit__: { type: 'object' },
208
+ __archived__: { type: 'boolean', defaultValue: false }
209
+ },
210
+ idGenerator: 'UUID',
211
+ deleteMode: 'soft'
212
+ }
213
+ };
214
+
215
+ function generateRbacJson() {
216
+ return JSON.stringify({
217
+ roles: {
218
+ admin: {
219
+ description: 'Full access to all tables, views, and methods',
220
+ capabilities: ['*:*', '*']
221
+ },
222
+ publisher: {
223
+ description: 'Can create and edit content — no structural changes',
224
+ capabilities: [
225
+ '*:list', '*:get',
226
+ 'pages:create', 'pages:update',
227
+ 'entries:create', 'entries:update',
228
+ 'categories:create', 'categories:update',
229
+ 'tags:create', 'tags:update',
230
+ 'media:create', 'media:update',
231
+ 'view:*',
232
+ 'getStats'
233
+ ],
234
+ filter: {
235
+ entries: 'author_id'
236
+ }
237
+ },
238
+ viewer: {
239
+ description: 'Read-only access — use views for filtered content',
240
+ capabilities: ['*:list', '*:get', 'view:publishedEntries', 'view:entriesByCategory']
241
+ }
242
+ },
243
+ defaultRole: 'viewer'
244
+ }, null, 2) + '\n';
245
+ }
246
+
247
+ function generateViewsJson() {
248
+ return JSON.stringify({}, null, 2) + '\n';
249
+ }
250
+
251
+ function generateCustomMethodsJson() {
252
+ return JSON.stringify({
253
+ getStats: {
254
+ handler: 'getStats.ts',
255
+ description: 'Example — get row counts for all tables'
256
+ }
257
+ }, null, 2) + '\n';
258
+ }
259
+
260
+ /**
261
+ * Scaffold the overrides/ directory with an example custom method handler.
262
+ */
263
+ function scaffoldExampleOverride(overridesDir) {
264
+ fs.mkdirSync(overridesDir, { recursive: true });
265
+
266
+ const exampleMethod = `/**
267
+ * Custom method handler: getStats
268
+ *
269
+ * Convention: export a function named handle_<actionName> that receives:
270
+ * - body: parsed request body
271
+ * - db: DBV2 instance
272
+ * - logger: logger instance
273
+ * - user: (optional, if RBAC enabled) { email, role, id }
274
+ *
275
+ * Must return a GoogleAppsScript.Content.TextOutput (use jsonResponse()).
276
+ *
277
+ * Declared in customMethods.json, handler files live in overrides/.
278
+ * Request: POST { "action": "getStats", "apiKey": "..." }
279
+ */
280
+ function handle_getStats(body: any, db: any, logger: any, user?: any): GoogleAppsScript.Content.TextOutput {
281
+ // Example: count rows in all tables
282
+ const schema = typeof DB_SCHEMA !== 'undefined' ? DB_SCHEMA : {};
283
+ const stats: Record<string, number> = {};
284
+
285
+ for (const tableName of Object.keys(schema)) {
286
+ const table = db.table(tableName);
287
+ if (table) {
288
+ const result = table.list({});
289
+ stats[tableName] = result?.data?.length || 0;
290
+ }
291
+ }
292
+
293
+ return jsonResponse({ stats });
294
+ }
295
+ `;
296
+ fs.writeFileSync(path.join(overridesDir, 'getStats.ts'), exampleMethod);
297
+ }
298
+
299
+ async function main() {
300
+ const args = parseArgs();
301
+
302
+ if (!args.db) {
303
+ console.error('Usage: node scripts/init.mjs --db <name> [--template <template>] [--instances rw,r,w] [--auth noAuth|simple|google] [--rbac] [--views]');
304
+ process.exit(1);
305
+ }
306
+
307
+ const dbName = args.db;
308
+
309
+ if (!/^[a-z0-9][a-z0-9_-]*$/i.test(dbName)) {
310
+ console.error(`Invalid DB name: "${dbName}"`);
311
+ console.error('Must contain only letters, numbers, hyphens, and underscores.');
312
+ process.exit(1);
313
+ }
314
+
315
+ const dbDir = path.join(DBS_DIR, dbName);
316
+ if (fs.existsSync(dbDir)) {
317
+ console.error(`DB project "${dbName}" already exists at ${dbDir}`);
318
+ process.exit(1);
319
+ }
320
+
321
+ const templates = loadTemplates();
322
+ let selectedTemplate;
323
+
324
+ if (args.template) {
325
+ selectedTemplate = templates.find(t => t.name === args.template);
326
+ if (!selectedTemplate) {
327
+ console.error(`Template "${args.template}" not found.`);
328
+ console.error(`Available templates: ${templates.map(t => t.name).join(', ')}`);
329
+ process.exit(1);
330
+ }
331
+ } else {
332
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
333
+
334
+ console.log('\nAvailable templates:\n');
335
+ templates.forEach((t, i) => {
336
+ const tableCount = Object.keys(t.tables).length;
337
+ console.log(` ${i + 1}. ${t.name} — ${t.description} (${tableCount} tables)`);
338
+ });
339
+ console.log('');
340
+
341
+ const answer = await ask(rl, `Select template (1-${templates.length}): `);
342
+ rl.close();
343
+
344
+ const idx = parseInt(answer, 10) - 1;
345
+ if (isNaN(idx) || idx < 0 || idx >= templates.length) {
346
+ console.error('Invalid selection.');
347
+ process.exit(1);
348
+ }
349
+
350
+ selectedTemplate = templates[idx];
351
+ }
352
+
353
+ const instances = resolveInstances(args);
354
+ const envDefaults = getEnvDefaults('dev');
355
+
356
+ // Resolve auth mode
357
+ const VALID_AUTH_MODES = ['noAuth', 'simple', 'google'];
358
+ const authMode = (typeof args.auth === 'string') ? args.auth : 'noAuth';
359
+ if (!VALID_AUTH_MODES.includes(authMode)) {
360
+ console.error(`Invalid auth mode: "${authMode}". Use: ${VALID_AUTH_MODES.join(', ')}`);
361
+ process.exit(1);
362
+ }
363
+
364
+ // Resolve feature flags
365
+ const features = {};
366
+ if (args.rbac) {
367
+ if (authMode === 'noAuth') {
368
+ console.error('RBAC requires --auth simple or --auth google (cannot use noAuth with RBAC).');
369
+ process.exit(1);
370
+ }
371
+ features.rbac = true;
372
+ }
373
+ if (args.views) {
374
+ features.views = true;
375
+ }
376
+
377
+ // Create the DB project directory
378
+ fs.mkdirSync(dbDir, { recursive: true });
379
+
380
+ // Merge RBAC tables into template tables if RBAC enabled
381
+ let tables = { ...selectedTemplate.tables };
382
+ if (features.rbac) {
383
+ tables = { ...RBAC_TABLES, ...tables };
384
+ }
385
+
386
+ // Write config files
387
+ fs.writeFileSync(path.join(dbDir, 'project.json'), generateProjectJson(dbName, instances, envDefaults, features, authMode));
388
+ fs.writeFileSync(path.join(dbDir, 'tables.json'), generateTablesJson(tables));
389
+
390
+ // RBAC config
391
+ if (features.rbac) {
392
+ fs.writeFileSync(path.join(dbDir, 'rbac.json'), generateRbacJson());
393
+ console.log(' Created rbac.json (3 default roles: admin, publisher, viewer)');
394
+ }
395
+
396
+ // Views config
397
+ if (features.views) {
398
+ fs.writeFileSync(path.join(dbDir, 'views.json'), generateViewsJson());
399
+ console.log(' Created views.json (empty — add views after setup)');
400
+ }
401
+
402
+ // Custom methods config — always created with example
403
+ fs.writeFileSync(path.join(dbDir, 'customMethods.json'), generateCustomMethodsJson());
404
+ console.log(' Created customMethods.json (1 example: getStats)');
405
+
406
+ // Scaffold library files
407
+ const libsDir = path.join(dbDir, 'libs');
408
+ console.log('\n Scaffolding libraries:');
409
+ const libCount = scaffoldLibs(libsDir);
410
+
411
+ // Scaffold overrides directory with example handler
412
+ const overridesDir = path.join(dbDir, 'overrides');
413
+ scaffoldExampleOverride(overridesDir);
414
+ console.log('\n Scaffolding overrides:');
415
+ console.log(' overrides/getStats.ts (example custom method handler)');
416
+
417
+ // Register project in .nsaproject.json
418
+ const rootConfig = loadRootConfig();
419
+ if (!rootConfig.kind) rootConfig.kind = 'db';
420
+ if (!rootConfig.environments) rootConfig.environments = {};
421
+ if (!rootConfig.projects) rootConfig.projects = [];
422
+
423
+ // Ensure env entries exist
424
+ for (const envName of ['dev', 'prod']) {
425
+ if (!rootConfig.environments[envName]) {
426
+ rootConfig.environments[envName] = {
427
+ account: '',
428
+ projectId: '',
429
+ ddlHandler: { scriptId: '', deploymentId: '', ddlAdminKey: '' }
430
+ };
431
+ }
432
+ }
433
+
434
+ // Store account from env defaults if available and not already set
435
+ if (envDefaults.account && !rootConfig.environments.dev.account) {
436
+ rootConfig.environments.dev.account = envDefaults.account;
437
+ }
438
+
439
+ // Add project to registry if not already listed
440
+ if (!rootConfig.projects.includes(dbName)) {
441
+ rootConfig.projects.push(dbName);
442
+ }
443
+
444
+ saveRootConfig(rootConfig);
445
+
446
+ const tableNames = Object.keys(tables);
447
+ const instanceKeys = Object.keys(instances);
448
+ console.log(`\nCreated DB project: ${dbName}`);
449
+ console.log(` Template: ${selectedTemplate.name}`);
450
+ console.log(` Tables: ${tableNames.join(', ')}`);
451
+ console.log(` Libs: ${libCount} files scaffolded`);
452
+ console.log(` Instances: ${instanceKeys.join(', ')}`);
453
+ console.log(` Auth: ${authMode}`);
454
+ if (features.rbac) console.log(` RBAC: enabled`);
455
+ if (features.views) console.log(` Views: enabled`);
456
+ console.log(` Path: ${dbDir}`);
457
+ console.log(` Registered in .nsaproject.json`);
458
+ console.log(`\nNext steps:`);
459
+ console.log(` 1. nsa-sheets-db-builder login --db ${dbName}`);
460
+ console.log(` 2. nsa-sheets-db-builder setup --db ${dbName}`);
461
+ if (features.rbac) console.log(`\n Optional: edit dbs/${dbName}/rbac.json before setup`);
462
+ if (features.views) console.log(` Optional: edit dbs/${dbName}/views.json before setup`);
463
+ }
464
+
465
+ main();