latticesql 3.0.0 → 3.1.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.
- package/dist/cli.js +2044 -695
- package/dist/index.cjs +561 -30
- package/dist/index.d.cts +220 -13
- package/dist/index.d.ts +220 -13
- package/dist/index.js +553 -30
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -41438,7 +41438,42 @@ function cleanupEntityContexts(outputDir, entityContexts, currentSlugsByTable, m
|
|
|
41438
41438
|
return result;
|
|
41439
41439
|
}
|
|
41440
41440
|
|
|
41441
|
+
// src/render/progress.ts
|
|
41442
|
+
var THROTTLE_WINDOW_MS = 200;
|
|
41443
|
+
var ProgressThrottle = class {
|
|
41444
|
+
cb;
|
|
41445
|
+
windowMs;
|
|
41446
|
+
lastEmit = 0;
|
|
41447
|
+
constructor(cb, windowMs = THROTTLE_WINDOW_MS) {
|
|
41448
|
+
this.cb = cb;
|
|
41449
|
+
this.windowMs = windowMs;
|
|
41450
|
+
}
|
|
41451
|
+
/**
|
|
41452
|
+
* Emit a `table-progress` event, but only if the window since the last
|
|
41453
|
+
* passthrough has elapsed. Dropped events are simply not delivered — the next
|
|
41454
|
+
* one that survives carries the latest running count.
|
|
41455
|
+
*/
|
|
41456
|
+
tick(event) {
|
|
41457
|
+
if (!this.cb) return;
|
|
41458
|
+
const now = Date.now();
|
|
41459
|
+
if (now - this.lastEmit < this.windowMs) return;
|
|
41460
|
+
this.lastEmit = now;
|
|
41461
|
+
this.cb(event);
|
|
41462
|
+
}
|
|
41463
|
+
/**
|
|
41464
|
+
* Emit a lifecycle event immediately and reset the throttle window. Use for
|
|
41465
|
+
* `table-start`, `table-done`, `done`, and `error` — none of which should
|
|
41466
|
+
* ever be dropped. Resetting on `table-start` gives each table a clean budget.
|
|
41467
|
+
*/
|
|
41468
|
+
force(event) {
|
|
41469
|
+
if (!this.cb) return;
|
|
41470
|
+
this.lastEmit = Date.now();
|
|
41471
|
+
this.cb(event);
|
|
41472
|
+
}
|
|
41473
|
+
};
|
|
41474
|
+
|
|
41441
41475
|
// src/render/engine.ts
|
|
41476
|
+
var YIELD_EVERY_ENTITIES = 200;
|
|
41442
41477
|
var NOOP_RENDER = () => "";
|
|
41443
41478
|
var RenderEngine = class {
|
|
41444
41479
|
_schema;
|
|
@@ -41452,11 +41487,14 @@ var RenderEngine = class {
|
|
|
41452
41487
|
this._getTaskContext = getTaskContext ?? (() => "");
|
|
41453
41488
|
this._skipEmpty = options?.skipEmpty ?? false;
|
|
41454
41489
|
}
|
|
41455
|
-
async render(outputDir) {
|
|
41490
|
+
async render(outputDir, opts = {}) {
|
|
41456
41491
|
const start = Date.now();
|
|
41457
41492
|
const filesWritten = [];
|
|
41458
41493
|
const counters = { skipped: 0 };
|
|
41494
|
+
const signal = opts.signal;
|
|
41495
|
+
const throttle = new ProgressThrottle(opts.onProgress);
|
|
41459
41496
|
for (const [name, def] of this._schema.getTables()) {
|
|
41497
|
+
if (signal?.aborted) return this._abortedResult(filesWritten, counters, start);
|
|
41460
41498
|
if (this._skipEmpty && def.render === NOOP_RENDER) continue;
|
|
41461
41499
|
let rows = await this._schema.queryTable(this._adapter, name);
|
|
41462
41500
|
if (def.relevanceFilter) {
|
|
@@ -41498,8 +41536,18 @@ var RenderEngine = class {
|
|
|
41498
41536
|
} else {
|
|
41499
41537
|
counters.skipped++;
|
|
41500
41538
|
}
|
|
41539
|
+
throttle.force({
|
|
41540
|
+
kind: "table-done",
|
|
41541
|
+
table: name,
|
|
41542
|
+
entitiesRendered: rows.length,
|
|
41543
|
+
entitiesTotal: rows.length,
|
|
41544
|
+
tableIndex: 0,
|
|
41545
|
+
tableCount: 0,
|
|
41546
|
+
pct: 100
|
|
41547
|
+
});
|
|
41501
41548
|
}
|
|
41502
|
-
for (const [, def] of this._schema.getMultis()) {
|
|
41549
|
+
for (const [name, def] of this._schema.getMultis()) {
|
|
41550
|
+
if (signal?.aborted) return this._abortedResult(filesWritten, counters, start);
|
|
41503
41551
|
const keys = await def.keys();
|
|
41504
41552
|
const tables = {};
|
|
41505
41553
|
if (def.tables) {
|
|
@@ -41516,12 +41564,26 @@ var RenderEngine = class {
|
|
|
41516
41564
|
counters.skipped++;
|
|
41517
41565
|
}
|
|
41518
41566
|
}
|
|
41567
|
+
throttle.force({
|
|
41568
|
+
kind: "table-done",
|
|
41569
|
+
table: name,
|
|
41570
|
+
entitiesRendered: keys.length,
|
|
41571
|
+
entitiesTotal: keys.length,
|
|
41572
|
+
tableIndex: 0,
|
|
41573
|
+
tableCount: 0,
|
|
41574
|
+
pct: 100
|
|
41575
|
+
});
|
|
41519
41576
|
}
|
|
41520
41577
|
const entityContextManifest = await this._renderEntityContexts(
|
|
41521
41578
|
outputDir,
|
|
41522
41579
|
filesWritten,
|
|
41523
|
-
counters
|
|
41580
|
+
counters,
|
|
41581
|
+
throttle,
|
|
41582
|
+
signal
|
|
41524
41583
|
);
|
|
41584
|
+
if (entityContextManifest === null) {
|
|
41585
|
+
return this._abortedResult(filesWritten, counters, start);
|
|
41586
|
+
}
|
|
41525
41587
|
if (this._schema.getEntityContexts().size > 0) {
|
|
41526
41588
|
writeManifest(outputDir, {
|
|
41527
41589
|
version: 2,
|
|
@@ -41529,6 +41591,29 @@ var RenderEngine = class {
|
|
|
41529
41591
|
entityContexts: entityContextManifest
|
|
41530
41592
|
});
|
|
41531
41593
|
}
|
|
41594
|
+
const result = {
|
|
41595
|
+
filesWritten,
|
|
41596
|
+
filesSkipped: counters.skipped,
|
|
41597
|
+
durationMs: Date.now() - start
|
|
41598
|
+
};
|
|
41599
|
+
throttle.force({
|
|
41600
|
+
kind: "done",
|
|
41601
|
+
table: null,
|
|
41602
|
+
entitiesRendered: 0,
|
|
41603
|
+
entitiesTotal: 0,
|
|
41604
|
+
tableIndex: 0,
|
|
41605
|
+
tableCount: 0,
|
|
41606
|
+
pct: 100,
|
|
41607
|
+
durationMs: result.durationMs
|
|
41608
|
+
});
|
|
41609
|
+
return result;
|
|
41610
|
+
}
|
|
41611
|
+
/**
|
|
41612
|
+
* Build the partial RenderResult to return when a render is aborted. No
|
|
41613
|
+
* `done` event is emitted — the caller treats abort as "discard the partial
|
|
41614
|
+
* tree", not as a successful completion.
|
|
41615
|
+
*/
|
|
41616
|
+
_abortedResult(filesWritten, counters, start) {
|
|
41532
41617
|
return {
|
|
41533
41618
|
filesWritten,
|
|
41534
41619
|
filesSkipped: counters.skipped,
|
|
@@ -41564,18 +41649,35 @@ var RenderEngine = class {
|
|
|
41564
41649
|
/**
|
|
41565
41650
|
* Render all entity context definitions.
|
|
41566
41651
|
* Mutates `filesWritten` and `counters` in place.
|
|
41567
|
-
* Returns manifest data for the entity contexts rendered this cycle
|
|
41652
|
+
* Returns manifest data for the entity contexts rendered this cycle, or
|
|
41653
|
+
* `null` if the render was aborted mid-flight (the caller discards the
|
|
41654
|
+
* partial tree). Progress is reported through `throttle`; abort is observed
|
|
41655
|
+
* via `signal`.
|
|
41568
41656
|
*/
|
|
41569
|
-
async _renderEntityContexts(outputDir, filesWritten, counters) {
|
|
41657
|
+
async _renderEntityContexts(outputDir, filesWritten, counters, throttle, signal) {
|
|
41570
41658
|
const manifestData = {};
|
|
41571
41659
|
const protectedTables = /* @__PURE__ */ new Set();
|
|
41572
41660
|
for (const [t8, d6] of this._schema.getEntityContexts()) {
|
|
41573
41661
|
if (d6.protected) protectedTables.add(t8);
|
|
41574
41662
|
}
|
|
41575
|
-
|
|
41663
|
+
const entityTables = [...this._schema.getEntityContexts()];
|
|
41664
|
+
const tableCount = entityTables.length;
|
|
41665
|
+
for (let tableIndex = 0; tableIndex < tableCount; tableIndex++) {
|
|
41666
|
+
if (signal?.aborted) return null;
|
|
41667
|
+
const [table, def] = entityTables[tableIndex];
|
|
41576
41668
|
const entityPk = this._schema.getPrimaryKey(table)[0] ?? "id";
|
|
41577
41669
|
const allRows = await this._schema.queryTable(this._adapter, table);
|
|
41578
41670
|
const directoryRoot = def.directoryRoot ?? table;
|
|
41671
|
+
const entitiesTotal = allRows.length;
|
|
41672
|
+
throttle.force({
|
|
41673
|
+
kind: "table-start",
|
|
41674
|
+
table,
|
|
41675
|
+
entitiesRendered: 0,
|
|
41676
|
+
entitiesTotal,
|
|
41677
|
+
tableIndex,
|
|
41678
|
+
tableCount,
|
|
41679
|
+
pct: 0
|
|
41680
|
+
});
|
|
41579
41681
|
const manifestEntry = {
|
|
41580
41682
|
directoryRoot,
|
|
41581
41683
|
...def.index ? { indexFile: def.index.outputFile } : {},
|
|
@@ -41591,7 +41693,12 @@ var RenderEngine = class {
|
|
|
41591
41693
|
counters.skipped++;
|
|
41592
41694
|
}
|
|
41593
41695
|
}
|
|
41594
|
-
for (
|
|
41696
|
+
for (let i6 = 0; i6 < allRows.length; i6++) {
|
|
41697
|
+
const entityRow = allRows[i6];
|
|
41698
|
+
if (signal?.aborted) return null;
|
|
41699
|
+
if (i6 > 0 && i6 % YIELD_EVERY_ENTITIES === 0) {
|
|
41700
|
+
await new Promise((r6) => setImmediate(r6));
|
|
41701
|
+
}
|
|
41595
41702
|
const rawSlug = def.slug(entityRow);
|
|
41596
41703
|
const slug = rawSlug.replace(/[\u00A0\u2000-\u200B\u202F\u205F\u3000]/g, " ").replace(/[\u0000-\u001F\u007F]/g, "");
|
|
41597
41704
|
if (/[^a-zA-Z0-9.\-_ @(),#&'+:;!~[\]]/.test(slug)) {
|
|
@@ -41636,6 +41743,7 @@ var RenderEngine = class {
|
|
|
41636
41743
|
const entityFileHashes = {};
|
|
41637
41744
|
const protection = protectedTables.size > 0 ? { protectedTables, currentTable: table } : void 0;
|
|
41638
41745
|
for (const [filename, spec] of Object.entries(def.files)) {
|
|
41746
|
+
if (signal?.aborted) return null;
|
|
41639
41747
|
const mergeDefaults = def.sourceDefaults && spec.source.type !== "self" && spec.source.type !== "custom" && spec.source.type !== "enriched";
|
|
41640
41748
|
const source = mergeDefaults ? { ...def.sourceDefaults, ...spec.source } : spec.source;
|
|
41641
41749
|
const rows = await resolveEntitySource(
|
|
@@ -41683,8 +41791,27 @@ var RenderEngine = class {
|
|
|
41683
41791
|
}
|
|
41684
41792
|
}
|
|
41685
41793
|
manifestEntry.entities[slug] = entityFileHashes;
|
|
41794
|
+
const entitiesRendered = i6 + 1;
|
|
41795
|
+
throttle.tick({
|
|
41796
|
+
kind: "table-progress",
|
|
41797
|
+
table,
|
|
41798
|
+
entitiesRendered,
|
|
41799
|
+
entitiesTotal,
|
|
41800
|
+
tableIndex,
|
|
41801
|
+
tableCount,
|
|
41802
|
+
pct: entitiesTotal > 0 ? entitiesRendered / entitiesTotal * 100 : 100
|
|
41803
|
+
});
|
|
41686
41804
|
}
|
|
41687
41805
|
manifestData[table] = manifestEntry;
|
|
41806
|
+
throttle.force({
|
|
41807
|
+
kind: "table-done",
|
|
41808
|
+
table,
|
|
41809
|
+
entitiesRendered: entitiesTotal,
|
|
41810
|
+
entitiesTotal,
|
|
41811
|
+
tableIndex,
|
|
41812
|
+
tableCount,
|
|
41813
|
+
pct: 100
|
|
41814
|
+
});
|
|
41688
41815
|
}
|
|
41689
41816
|
return manifestData;
|
|
41690
41817
|
}
|
|
@@ -44291,6 +44418,54 @@ var Lattice = class _Lattice {
|
|
|
44291
44418
|
async insert(table, row, provenance) {
|
|
44292
44419
|
const notInit = this._notInitError();
|
|
44293
44420
|
if (notInit) return notInit;
|
|
44421
|
+
const { sql, values, pkValue, rowWithPk } = this._prepareInsert(table, row);
|
|
44422
|
+
await runAsyncOrSync(this._adapter, sql, values);
|
|
44423
|
+
await this._afterInsert(table, pkValue, rowWithPk, provenance);
|
|
44424
|
+
return pkValue;
|
|
44425
|
+
}
|
|
44426
|
+
/**
|
|
44427
|
+
* Insert a row while atomically forcing its cloud row-visibility, regardless of
|
|
44428
|
+
* the table's `default_row_visibility`. The per-table insert trigger reads a
|
|
44429
|
+
* transaction-local GUC (`lattice.force_row_visibility`); we set it and run the
|
|
44430
|
+
* INSERT inside a single transaction, so the row is stamped at `visibility` the
|
|
44431
|
+
* instant it exists — it is never momentarily visible at the table default, and
|
|
44432
|
+
* the change-feed `NOTIFY` (delivered only at COMMIT) fires when the row already
|
|
44433
|
+
* carries this visibility. This closes the create-then-demote window that a
|
|
44434
|
+
* plain `insert()` + `setRowVisibility()` would leave open.
|
|
44435
|
+
*
|
|
44436
|
+
* Postgres-only: SQLite is single-user (no cross-viewer leak) and has no trigger
|
|
44437
|
+
* to read the GUC, so it degrades to a plain {@link insert}. A `never_share`
|
|
44438
|
+
* table still wins — its rows are forced private even if `visibility` is
|
|
44439
|
+
* `'everyone'` (the trigger enforces that precedence).
|
|
44440
|
+
*
|
|
44441
|
+
* @since 3.1.0
|
|
44442
|
+
*/
|
|
44443
|
+
async insertForcingVisibility(table, row, visibility, provenance) {
|
|
44444
|
+
const notInit = this._notInitError();
|
|
44445
|
+
if (notInit) return notInit;
|
|
44446
|
+
const vis = visibility;
|
|
44447
|
+
if (vis !== "private" && vis !== "everyone") {
|
|
44448
|
+
throw new Error(`lattice: invalid forced visibility "${vis}"`);
|
|
44449
|
+
}
|
|
44450
|
+
const withClient = this._adapter.withClient?.bind(this._adapter);
|
|
44451
|
+
if (this.getDialect() !== "postgres" || !withClient) {
|
|
44452
|
+
return this.insert(table, row, provenance);
|
|
44453
|
+
}
|
|
44454
|
+
const { sql, values, pkValue, rowWithPk } = this._prepareInsert(table, row);
|
|
44455
|
+
await withClient(async (tx) => {
|
|
44456
|
+
await tx.run(`SELECT set_config('lattice.force_row_visibility', ?, true)`, [visibility]);
|
|
44457
|
+
await tx.run(sql, values);
|
|
44458
|
+
});
|
|
44459
|
+
await this._afterInsert(table, pkValue, rowWithPk, provenance);
|
|
44460
|
+
return pkValue;
|
|
44461
|
+
}
|
|
44462
|
+
/**
|
|
44463
|
+
* Build the INSERT statement + canonical pk for a row (sanitize → schema-filter →
|
|
44464
|
+
* auto-pk → encrypt). Shared by {@link insert} and {@link insertForcingVisibility}
|
|
44465
|
+
* so both produce byte-identical writes; the latter only differs in running it
|
|
44466
|
+
* inside a GUC-scoped transaction.
|
|
44467
|
+
*/
|
|
44468
|
+
_prepareInsert(table, row) {
|
|
44294
44469
|
this._assertIdent(table);
|
|
44295
44470
|
const sanitized = this._filterToSchemaColumns(table, this._sanitizer.sanitizeRow(row));
|
|
44296
44471
|
const pkCols = this._schema.getPrimaryKey(table);
|
|
@@ -44306,12 +44481,17 @@ var Lattice = class _Lattice {
|
|
|
44306
44481
|
const cols = Object.keys(encrypted).map((c6) => `"${c6}"`).join(", ");
|
|
44307
44482
|
const placeholders = Object.keys(encrypted).map(() => "?").join(", ");
|
|
44308
44483
|
const values = Object.values(encrypted);
|
|
44309
|
-
await runAsyncOrSync(
|
|
44310
|
-
this._adapter,
|
|
44311
|
-
`INSERT INTO "${table}" (${cols}) VALUES (${placeholders})`,
|
|
44312
|
-
values
|
|
44313
|
-
);
|
|
44314
44484
|
const pkValue = this._serializeRowPk(table, rowWithPk);
|
|
44485
|
+
return {
|
|
44486
|
+
sql: `INSERT INTO "${table}" (${cols}) VALUES (${placeholders})`,
|
|
44487
|
+
values,
|
|
44488
|
+
pkValue,
|
|
44489
|
+
rowWithPk
|
|
44490
|
+
};
|
|
44491
|
+
}
|
|
44492
|
+
/** Post-insert side effects (changelog, audit, write hooks, embedding sync),
|
|
44493
|
+
* identical for the plain and force-visibility insert paths. */
|
|
44494
|
+
async _afterInsert(table, pkValue, rowWithPk, provenance) {
|
|
44315
44495
|
await this._appendChangelog(
|
|
44316
44496
|
table,
|
|
44317
44497
|
pkValue,
|
|
@@ -44325,7 +44505,6 @@ var Lattice = class _Lattice {
|
|
|
44325
44505
|
this._sanitizer.emitAudit(table, "insert", pkValue);
|
|
44326
44506
|
await this._fireWriteHooks(table, "insert", rowWithPk, pkValue);
|
|
44327
44507
|
this._syncEmbedding(table, "insert", rowWithPk, pkValue);
|
|
44328
|
-
return pkValue;
|
|
44329
44508
|
}
|
|
44330
44509
|
/**
|
|
44331
44510
|
* Insert a row and return the full inserted row (including auto-generated
|
|
@@ -44392,6 +44571,7 @@ var Lattice = class _Lattice {
|
|
|
44392
44571
|
const sanitized = this._filterToSchemaColumns(table, this._sanitizer.sanitizeRow(row));
|
|
44393
44572
|
const encrypted = this._encryptRow(table, sanitized);
|
|
44394
44573
|
const setCols = Object.keys(encrypted).map((c6) => `"${c6}" = ?`).join(", ");
|
|
44574
|
+
if (setCols === "") return;
|
|
44395
44575
|
const { clause, params: pkParams } = this._pkWhere(table, id);
|
|
44396
44576
|
let previousValues = null;
|
|
44397
44577
|
if (this._changelogTables.has(table)) {
|
|
@@ -45008,13 +45188,32 @@ var Lattice = class _Lattice {
|
|
|
45008
45188
|
// -------------------------------------------------------------------------
|
|
45009
45189
|
// Sync
|
|
45010
45190
|
// -------------------------------------------------------------------------
|
|
45011
|
-
async render(outputDir) {
|
|
45191
|
+
async render(outputDir, opts = {}) {
|
|
45012
45192
|
const notInit = this._notInitError();
|
|
45013
45193
|
if (notInit) return notInit;
|
|
45014
|
-
const result = await this._render.render(outputDir);
|
|
45194
|
+
const result = await this._render.render(outputDir, opts);
|
|
45015
45195
|
for (const h6 of this._renderHandlers) h6(result);
|
|
45016
45196
|
return result;
|
|
45017
45197
|
}
|
|
45198
|
+
/**
|
|
45199
|
+
* Render into `outputDir` through the shared single-flight guard, intended to
|
|
45200
|
+
* be called fire-and-forget (e.g. the GUI's instant-open background render).
|
|
45201
|
+
*
|
|
45202
|
+
* The guard ({@link _renderGuarded}) holds {@link _autoRenderInFlight} for the
|
|
45203
|
+
* render's duration, so a data mutation that lands while this render is in
|
|
45204
|
+
* flight is deferred by {@link _runAutoRender} and coalesced — when this
|
|
45205
|
+
* render settles, `finally` clears the flag and re-arms exactly one follow-up
|
|
45206
|
+
* render via {@link _rearmAutoRenderIfPending}. Net invariant: at most one
|
|
45207
|
+
* render to a given dir at a time.
|
|
45208
|
+
*
|
|
45209
|
+
* Errors propagate to the caller (the GUI surfaces them, never silently swallowed); they are
|
|
45210
|
+
* not swallowed here.
|
|
45211
|
+
*/
|
|
45212
|
+
async renderInBackground(outputDir, opts = {}) {
|
|
45213
|
+
const notInit = this._notInitError();
|
|
45214
|
+
if (notInit) return notInit;
|
|
45215
|
+
return this._renderGuarded(outputDir, opts);
|
|
45216
|
+
}
|
|
45018
45217
|
async sync(outputDir) {
|
|
45019
45218
|
const notInit = this._notInitError();
|
|
45020
45219
|
if (notInit) return notInit;
|
|
@@ -45356,6 +45555,30 @@ var Lattice = class _Lattice {
|
|
|
45356
45555
|
}, this._autoRenderDebounceMs);
|
|
45357
45556
|
this._autoRenderTimer.unref();
|
|
45358
45557
|
}
|
|
45558
|
+
/**
|
|
45559
|
+
* Shared single-flight render path used by {@link renderInBackground}.
|
|
45560
|
+
*
|
|
45561
|
+
* Holds {@link _autoRenderInFlight} for the render's duration so the
|
|
45562
|
+
* mutation-driven {@link _runAutoRender} defers while this render runs (it
|
|
45563
|
+
* sees the flag and marks itself pending instead of starting a second,
|
|
45564
|
+
* overlapping render). On settle, `finally` clears the flag and re-arms a
|
|
45565
|
+
* single coalesced follow-up render if any mutation arrived mid-flight.
|
|
45566
|
+
* Errors propagate to the caller; the flag is always cleared.
|
|
45567
|
+
*/
|
|
45568
|
+
async _renderGuarded(outputDir, opts) {
|
|
45569
|
+
while (this._autoRenderInFlight) {
|
|
45570
|
+
await new Promise((r6) => setImmediate(r6));
|
|
45571
|
+
}
|
|
45572
|
+
this._autoRenderInFlight = true;
|
|
45573
|
+
try {
|
|
45574
|
+
const result = await this._render.render(outputDir, opts);
|
|
45575
|
+
for (const h6 of this._renderHandlers) h6(result);
|
|
45576
|
+
return result;
|
|
45577
|
+
} finally {
|
|
45578
|
+
this._autoRenderInFlight = false;
|
|
45579
|
+
this._rearmAutoRenderIfPending();
|
|
45580
|
+
}
|
|
45581
|
+
}
|
|
45359
45582
|
async _runAutoRender() {
|
|
45360
45583
|
const dir = this._autoRenderDir;
|
|
45361
45584
|
if (!dir || !this._initialized) return;
|
|
@@ -46983,6 +47206,31 @@ function pkSqlExpr(pkCols, prefix) {
|
|
|
46983
47206
|
return pkCols.map((c6) => `CAST(${prefix}"${c6}" AS TEXT)`).join(` || chr(9) || `);
|
|
46984
47207
|
}
|
|
46985
47208
|
var MEMBER_GROUP = "lattice_members";
|
|
47209
|
+
function pinDefinerSearchPath(sql, schema) {
|
|
47210
|
+
const safe = schema.replace(/"/g, '""');
|
|
47211
|
+
return sql.replace(
|
|
47212
|
+
/SECURITY DEFINER AS/g,
|
|
47213
|
+
`SECURITY DEFINER SET search_path = "${safe}", pg_temp AS`
|
|
47214
|
+
);
|
|
47215
|
+
}
|
|
47216
|
+
async function cloudSchema(db) {
|
|
47217
|
+
const row = await getAsyncOrSync(db.adapter, `SELECT current_schema() AS schema`);
|
|
47218
|
+
const s2 = row?.schema;
|
|
47219
|
+
if (typeof s2 !== "string" || s2.length === 0) {
|
|
47220
|
+
throw new Error("cloud RLS: could not resolve current_schema() for search_path pinning");
|
|
47221
|
+
}
|
|
47222
|
+
return s2;
|
|
47223
|
+
}
|
|
47224
|
+
function revokeSchemaCreateSql(schema) {
|
|
47225
|
+
const lit = `'${schema.replace(/'/g, "''")}'`;
|
|
47226
|
+
return `
|
|
47227
|
+
DO $LATTICE_REVOKE$ BEGIN
|
|
47228
|
+
EXECUTE format('REVOKE CREATE ON SCHEMA %I FROM PUBLIC', ${lit});
|
|
47229
|
+
EXCEPTION WHEN OTHERS THEN
|
|
47230
|
+
NULL; -- not the schema owner, or already revoked
|
|
47231
|
+
END $LATTICE_REVOKE$;
|
|
47232
|
+
`;
|
|
47233
|
+
}
|
|
46986
47234
|
var CLOUD_RLS_BOOTSTRAP_SQL = `
|
|
46987
47235
|
-- Member group (NOLOGIN). Members inherit schema/connect/table privileges from it;
|
|
46988
47236
|
-- RLS filters per the individual member's login role, so the group never widens
|
|
@@ -47042,6 +47290,52 @@ CREATE TABLE IF NOT EXISTS "__lattice_cell_grants" (
|
|
|
47042
47290
|
PRIMARY KEY ("table_name", "pk", "column_name", "grantee_role")
|
|
47043
47291
|
);
|
|
47044
47292
|
|
|
47293
|
+
-- Per-table policy: the owner-controlled defaults that govern a whole table.
|
|
47294
|
+
-- default_row_visibility is the visibility NEW rows are stamped with (the insert
|
|
47295
|
+
-- trigger reads it); never_share is a hard exclusion \u2014 the share/grant functions
|
|
47296
|
+
-- refuse to elevate such a table and the trigger forces its rows private. Owner-
|
|
47297
|
+
-- managed; members have no grant (it never appears in their data API).
|
|
47298
|
+
CREATE TABLE IF NOT EXISTS "__lattice_table_policy" (
|
|
47299
|
+
"table_name" text PRIMARY KEY,
|
|
47300
|
+
"default_row_visibility" text NOT NULL DEFAULT 'private'
|
|
47301
|
+
CHECK ("default_row_visibility" IN ('private','everyone')),
|
|
47302
|
+
"never_share" boolean NOT NULL DEFAULT false,
|
|
47303
|
+
"updated_by" text NOT NULL DEFAULT session_user,
|
|
47304
|
+
"updated_at" timestamptz NOT NULL DEFAULT now()
|
|
47305
|
+
);
|
|
47306
|
+
|
|
47307
|
+
-- Per-column audience policy: the CANONICAL store of which column carries which
|
|
47308
|
+
-- audience spec (role: / subject: / source: / owner / everyone). Previously the
|
|
47309
|
+
-- spec lived only in the owner's on-disk YAML and was compiled into the mask view
|
|
47310
|
+
-- once at init; storing it here makes it cloud-canonical and member-consistent.
|
|
47311
|
+
-- The generated <table>_v mask view is regenerated from THIS table on change.
|
|
47312
|
+
-- Owner-managed; members have no grant.
|
|
47313
|
+
CREATE TABLE IF NOT EXISTS "__lattice_column_policy" (
|
|
47314
|
+
"table_name" text NOT NULL,
|
|
47315
|
+
"column_name" text NOT NULL,
|
|
47316
|
+
"audience" text NOT NULL,
|
|
47317
|
+
"updated_by" text NOT NULL DEFAULT session_user,
|
|
47318
|
+
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
|
47319
|
+
PRIMARY KEY ("table_name", "column_name")
|
|
47320
|
+
);
|
|
47321
|
+
|
|
47322
|
+
-- Owner-only audit of issued member invites: which scoped role was minted for
|
|
47323
|
+
-- which email (HASHED \u2014 the plaintext email is never stored), when it expires,
|
|
47324
|
+
-- and whether it was redeemed/revoked. No plaintext password is ever stored
|
|
47325
|
+
-- (the credential lives only inside the email-bound token the owner delivers).
|
|
47326
|
+
-- Owner-managed; members have no grant. Named distinctly from any legacy
|
|
47327
|
+
-- team-model invitations table so a pre-existing cloud never collides.
|
|
47328
|
+
CREATE TABLE IF NOT EXISTS "__lattice_member_invites" (
|
|
47329
|
+
"id" text PRIMARY KEY,
|
|
47330
|
+
"role" text NOT NULL,
|
|
47331
|
+
"email_hash" text NOT NULL,
|
|
47332
|
+
"created_by" text NOT NULL DEFAULT session_user,
|
|
47333
|
+
"created_at" timestamptz NOT NULL DEFAULT now(),
|
|
47334
|
+
"expires_at" timestamptz NOT NULL,
|
|
47335
|
+
"redeemed_at" timestamptz,
|
|
47336
|
+
"revoked_at" timestamptz
|
|
47337
|
+
);
|
|
47338
|
+
|
|
47045
47339
|
-- Visibility check. SECURITY DEFINER so it reads bookkeeping the member can't;
|
|
47046
47340
|
-- keyed on session_user (the member's login role). A row with no ownership record
|
|
47047
47341
|
-- is visible to nobody.
|
|
@@ -47067,6 +47361,10 @@ BEGIN
|
|
|
47067
47361
|
IF p_visibility NOT IN ('private','everyone','custom') THEN
|
|
47068
47362
|
RAISE EXCEPTION 'lattice: invalid visibility %', p_visibility;
|
|
47069
47363
|
END IF;
|
|
47364
|
+
IF p_visibility <> 'private'
|
|
47365
|
+
AND COALESCE((SELECT "never_share" FROM "__lattice_table_policy" WHERE "table_name" = p_table), false) THEN
|
|
47366
|
+
RAISE EXCEPTION 'lattice: "%" is a private-only table and cannot be shared', p_table;
|
|
47367
|
+
END IF;
|
|
47070
47368
|
SELECT o."owner_role" INTO v_owner FROM "__lattice_owners" o
|
|
47071
47369
|
WHERE o."table_name" = p_table AND o."pk" = p_pk;
|
|
47072
47370
|
IF v_owner IS NULL THEN RAISE EXCEPTION 'lattice: no ownership record for %/%', p_table, p_pk; END IF;
|
|
@@ -47080,6 +47378,9 @@ CREATE OR REPLACE FUNCTION lattice_grant_row(p_table text, p_pk text, p_grantee
|
|
|
47080
47378
|
RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $fn$
|
|
47081
47379
|
DECLARE v_owner text;
|
|
47082
47380
|
BEGIN
|
|
47381
|
+
IF COALESCE((SELECT "never_share" FROM "__lattice_table_policy" WHERE "table_name" = p_table), false) THEN
|
|
47382
|
+
RAISE EXCEPTION 'lattice: "%" is a private-only table and cannot be shared', p_table;
|
|
47383
|
+
END IF;
|
|
47083
47384
|
SELECT o."owner_role" INTO v_owner FROM "__lattice_owners" o
|
|
47084
47385
|
WHERE o."table_name" = p_table AND o."pk" = p_pk;
|
|
47085
47386
|
IF v_owner IS NULL THEN RAISE EXCEPTION 'lattice: no ownership record for %/%', p_table, p_pk; END IF;
|
|
@@ -47169,6 +47470,9 @@ CREATE OR REPLACE FUNCTION lattice_grant_cell(p_table text, p_pk text, p_column
|
|
|
47169
47470
|
RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $fn$
|
|
47170
47471
|
DECLARE v_owner text;
|
|
47171
47472
|
BEGIN
|
|
47473
|
+
IF COALESCE((SELECT "never_share" FROM "__lattice_table_policy" WHERE "table_name" = p_table), false) THEN
|
|
47474
|
+
RAISE EXCEPTION 'lattice: "%" is a private-only table and cannot be shared', p_table;
|
|
47475
|
+
END IF;
|
|
47172
47476
|
SELECT o."owner_role" INTO v_owner FROM "__lattice_owners" o
|
|
47173
47477
|
WHERE o."table_name" = p_table AND o."pk" = p_pk;
|
|
47174
47478
|
IF v_owner IS NULL OR v_owner <> session_user THEN
|
|
@@ -47201,6 +47505,87 @@ RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER AS $fn$
|
|
|
47201
47505
|
SELECT lattice_row_visible('files', p_source_ref)
|
|
47202
47506
|
$fn$;
|
|
47203
47507
|
|
|
47508
|
+
-- Is the connected member the OWNER of this row? Used by the "owner" column
|
|
47509
|
+
-- audience (a secret column reveals only to the row owner). SECURITY DEFINER +
|
|
47510
|
+
-- session_user, like the other predicates.
|
|
47511
|
+
CREATE OR REPLACE FUNCTION lattice_is_owner(p_table text, p_pk text)
|
|
47512
|
+
RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER AS $fn$
|
|
47513
|
+
SELECT EXISTS (
|
|
47514
|
+
SELECT 1 FROM "__lattice_owners" o
|
|
47515
|
+
WHERE o."table_name" = p_table AND o."pk" = p_pk AND o."owner_role" = session_user
|
|
47516
|
+
)
|
|
47517
|
+
$fn$;
|
|
47518
|
+
|
|
47519
|
+
-- Owner-only: set a table's default row visibility for NEW rows. Raises unless the
|
|
47520
|
+
-- caller can create roles (a cloud owner / DBA), like lattice_assign_role. Rejects
|
|
47521
|
+
-- 'everyone' on a never-share table.
|
|
47522
|
+
CREATE OR REPLACE FUNCTION lattice_set_table_default_visibility(p_table text, p_visibility text)
|
|
47523
|
+
RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $fn$
|
|
47524
|
+
BEGIN
|
|
47525
|
+
IF NOT (SELECT rolcreaterole FROM pg_roles WHERE rolname = session_user) THEN
|
|
47526
|
+
RAISE EXCEPTION 'lattice: only a cloud owner may set a table''s default visibility';
|
|
47527
|
+
END IF;
|
|
47528
|
+
IF p_visibility NOT IN ('private','everyone') THEN
|
|
47529
|
+
RAISE EXCEPTION 'lattice: invalid default visibility %', p_visibility;
|
|
47530
|
+
END IF;
|
|
47531
|
+
IF p_visibility = 'everyone'
|
|
47532
|
+
AND COALESCE((SELECT "never_share" FROM "__lattice_table_policy" WHERE "table_name" = p_table), false) THEN
|
|
47533
|
+
RAISE EXCEPTION 'lattice: "%" is a private-only table; its rows cannot default to everyone', p_table;
|
|
47534
|
+
END IF;
|
|
47535
|
+
INSERT INTO "__lattice_table_policy" ("table_name","default_row_visibility","updated_by","updated_at")
|
|
47536
|
+
VALUES (p_table, p_visibility, session_user, now())
|
|
47537
|
+
ON CONFLICT ("table_name") DO UPDATE
|
|
47538
|
+
SET "default_row_visibility" = EXCLUDED."default_row_visibility",
|
|
47539
|
+
"updated_by" = session_user, "updated_at" = now();
|
|
47540
|
+
END $fn$;
|
|
47541
|
+
|
|
47542
|
+
-- Owner-only: mark a table never-shareable (Secrets/Messages-class). When true the
|
|
47543
|
+
-- share/grant functions raise and the insert trigger forces new rows private; the
|
|
47544
|
+
-- default visibility is also forced private. Turning it ON also RETROACTIVELY
|
|
47545
|
+
-- privatizes the table: any row currently shared ('everyone'/'custom') is reset to
|
|
47546
|
+
-- 'private' and every existing row/cell grant on the table is dropped \u2014 otherwise
|
|
47547
|
+
-- flagging a table never-share would leave already-leaked rows visible, defeating
|
|
47548
|
+
-- the point. Idempotent: re-running with already-private rows updates nothing.
|
|
47549
|
+
CREATE OR REPLACE FUNCTION lattice_set_table_never_share(p_table text, p_on boolean)
|
|
47550
|
+
RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $fn$
|
|
47551
|
+
BEGIN
|
|
47552
|
+
IF NOT (SELECT rolcreaterole FROM pg_roles WHERE rolname = session_user) THEN
|
|
47553
|
+
RAISE EXCEPTION 'lattice: only a cloud owner may change a table''s never-share flag';
|
|
47554
|
+
END IF;
|
|
47555
|
+
INSERT INTO "__lattice_table_policy" ("table_name","never_share","default_row_visibility","updated_by","updated_at")
|
|
47556
|
+
VALUES (p_table, p_on, CASE WHEN p_on THEN 'private' ELSE 'private' END, session_user, now())
|
|
47557
|
+
ON CONFLICT ("table_name") DO UPDATE
|
|
47558
|
+
SET "never_share" = EXCLUDED."never_share",
|
|
47559
|
+
"default_row_visibility" = CASE WHEN EXCLUDED."never_share"
|
|
47560
|
+
THEN 'private' ELSE "__lattice_table_policy"."default_row_visibility" END,
|
|
47561
|
+
"updated_by" = session_user, "updated_at" = now();
|
|
47562
|
+
IF p_on THEN
|
|
47563
|
+
UPDATE "__lattice_owners" SET "visibility" = 'private', "updated_at" = now()
|
|
47564
|
+
WHERE "table_name" = p_table AND "visibility" <> 'private';
|
|
47565
|
+
DELETE FROM "__lattice_row_grants" WHERE "table_name" = p_table;
|
|
47566
|
+
DELETE FROM "__lattice_cell_grants" WHERE "table_name" = p_table;
|
|
47567
|
+
END IF;
|
|
47568
|
+
END $fn$;
|
|
47569
|
+
|
|
47570
|
+
-- Owner-only: set (or clear) a column's audience spec in the canonical DB store.
|
|
47571
|
+
-- An empty/null spec removes the policy row (column becomes unmasked). The GUI/lib
|
|
47572
|
+
-- regenerates the table's mask view from this store after calling this.
|
|
47573
|
+
CREATE OR REPLACE FUNCTION lattice_set_column_audience(p_table text, p_column text, p_audience text)
|
|
47574
|
+
RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $fn$
|
|
47575
|
+
BEGIN
|
|
47576
|
+
IF NOT (SELECT rolcreaterole FROM pg_roles WHERE rolname = session_user) THEN
|
|
47577
|
+
RAISE EXCEPTION 'lattice: only a cloud owner may set a column audience';
|
|
47578
|
+
END IF;
|
|
47579
|
+
IF p_audience IS NULL OR btrim(p_audience) = '' THEN
|
|
47580
|
+
DELETE FROM "__lattice_column_policy" WHERE "table_name" = p_table AND "column_name" = p_column;
|
|
47581
|
+
ELSE
|
|
47582
|
+
INSERT INTO "__lattice_column_policy" ("table_name","column_name","audience","updated_by","updated_at")
|
|
47583
|
+
VALUES (p_table, p_column, p_audience, session_user, now())
|
|
47584
|
+
ON CONFLICT ("table_name","column_name") DO UPDATE
|
|
47585
|
+
SET "audience" = EXCLUDED."audience", "updated_by" = session_user, "updated_at" = now();
|
|
47586
|
+
END IF;
|
|
47587
|
+
END $fn$;
|
|
47588
|
+
|
|
47204
47589
|
-- Append-only change feed. The per-table ownership trigger records one row per
|
|
47205
47590
|
-- INSERT/UPDATE/DELETE; the AFTER INSERT trigger here fires pg_notify so a
|
|
47206
47591
|
-- connected member's realtime broker refreshes. Members get no direct access \u2014
|
|
@@ -47245,7 +47630,20 @@ CREATE OR REPLACE FUNCTION "${trg}"() RETURNS trigger LANGUAGE plpgsql SECURITY
|
|
|
47245
47630
|
BEGIN
|
|
47246
47631
|
IF TG_OP = 'INSERT' THEN
|
|
47247
47632
|
INSERT INTO "__lattice_owners" ("table_name","pk","owner_role","visibility")
|
|
47248
|
-
VALUES (${lit}, ${pkNew}, session_user,
|
|
47633
|
+
VALUES (${lit}, ${pkNew}, session_user,
|
|
47634
|
+
CASE
|
|
47635
|
+
-- never-share always wins: such a table's rows are private, full stop.
|
|
47636
|
+
WHEN COALESCE((SELECT "never_share" FROM "__lattice_table_policy" WHERE "table_name" = ${lit}), false)
|
|
47637
|
+
THEN 'private'
|
|
47638
|
+
-- per-INSERT override: a caller forcing visibility for THIS write (e.g.
|
|
47639
|
+
-- chat "private mode") sets the transaction-local lattice.force_row_visibility
|
|
47640
|
+
-- GUC, so the row is stamped atomically at insert \u2014 never momentarily at
|
|
47641
|
+
-- the table default, and the change-feed NOTIFY (deferred to COMMIT) only
|
|
47642
|
+
-- fires once the row already carries this visibility.
|
|
47643
|
+
WHEN NULLIF(current_setting('lattice.force_row_visibility', true), '') IN ('private','everyone')
|
|
47644
|
+
THEN current_setting('lattice.force_row_visibility', true)
|
|
47645
|
+
ELSE COALESCE((SELECT "default_row_visibility" FROM "__lattice_table_policy" WHERE "table_name" = ${lit}), 'private')
|
|
47646
|
+
END)
|
|
47249
47647
|
ON CONFLICT ("table_name","pk") DO NOTHING;
|
|
47250
47648
|
INSERT INTO "__lattice_changes" ("table_name","pk","op","owner_role")
|
|
47251
47649
|
VALUES (${lit}, ${pkNew}, 'upsert', session_user);
|
|
@@ -47285,19 +47683,28 @@ CREATE TRIGGER "${trg}" AFTER INSERT OR UPDATE OR DELETE ON ${q3}
|
|
|
47285
47683
|
}
|
|
47286
47684
|
async function installCloudRls(db) {
|
|
47287
47685
|
if (!isPg(db)) return;
|
|
47686
|
+
const schema = await cloudSchema(db);
|
|
47288
47687
|
const migration = {
|
|
47289
47688
|
// v3 added the audience helpers; v4 the role model; v5 the per-card override
|
|
47290
|
-
// model (__lattice_cell_grants + lattice_cell_visible / lattice_grant_cell)
|
|
47291
|
-
//
|
|
47292
|
-
|
|
47293
|
-
|
|
47689
|
+
// model (__lattice_cell_grants + lattice_cell_visible / lattice_grant_cell);
|
|
47690
|
+
// v6 added per-table policy (__lattice_table_policy: default_row_visibility +
|
|
47691
|
+
// never_share, enforced in the insert trigger + share/grant guards), the
|
|
47692
|
+
// canonical column-audience store (__lattice_column_policy), lattice_is_owner,
|
|
47693
|
+
// and the owner-only setters; v7 pins search_path on every SECURITY DEFINER
|
|
47694
|
+
// helper (closes the pg_temp-shadow RLS bypass) + revokes schema CREATE from
|
|
47695
|
+
// PUBLIC. The bootstrap is fully idempotent.
|
|
47696
|
+
version: "internal:cloud-rls:bootstrap:v7",
|
|
47697
|
+
sql: pinDefinerSearchPath(CLOUD_RLS_BOOTSTRAP_SQL, schema) + revokeSchemaCreateSql(schema)
|
|
47294
47698
|
};
|
|
47295
47699
|
await db.migrate([migration]);
|
|
47296
47700
|
}
|
|
47297
47701
|
async function enableChangelogRls(db) {
|
|
47298
47702
|
if (!isPg(db)) return;
|
|
47299
47703
|
const migration = {
|
|
47300
|
-
|
|
47704
|
+
// v2: ground-truth/audit entries are owner-only (was lattice_row_visible),
|
|
47705
|
+
// closing the masked-column-via-history leak. Bump re-installs the policy on
|
|
47706
|
+
// existing clouds.
|
|
47707
|
+
version: "internal:cloud-rls:changelog:v2",
|
|
47301
47708
|
sql: `
|
|
47302
47709
|
ALTER TABLE "__lattice_changelog" ENABLE ROW LEVEL SECURITY;
|
|
47303
47710
|
ALTER TABLE "__lattice_changelog" FORCE ROW LEVEL SECURITY;
|
|
@@ -47312,7 +47719,7 @@ CREATE POLICY "lattice_changelog_sel" ON "__lattice_changelog" FOR SELECT USING
|
|
|
47312
47719
|
SELECT 1 FROM jsonb_array_elements_text("source_ref"::jsonb) AS src(sid)
|
|
47313
47720
|
WHERE NOT lattice_source_visible(src.sid)
|
|
47314
47721
|
)
|
|
47315
|
-
ELSE
|
|
47722
|
+
ELSE lattice_is_owner("table_name", "row_id")
|
|
47316
47723
|
END
|
|
47317
47724
|
);
|
|
47318
47725
|
DROP POLICY IF EXISTS "lattice_changelog_ins" ON "__lattice_changelog";
|
|
@@ -47323,9 +47730,10 @@ CREATE POLICY "lattice_changelog_ins" ON "__lattice_changelog" FOR INSERT WITH C
|
|
|
47323
47730
|
}
|
|
47324
47731
|
async function enableRlsForTable(db, table, pkCols) {
|
|
47325
47732
|
if (!isPg(db)) return;
|
|
47733
|
+
const schema = await cloudSchema(db);
|
|
47326
47734
|
const migration = {
|
|
47327
|
-
version: `internal:cloud-rls:table:${table}:
|
|
47328
|
-
sql: tableRlsSql(table, pkCols)
|
|
47735
|
+
version: `internal:cloud-rls:table:${table}:v3`,
|
|
47736
|
+
sql: pinDefinerSearchPath(tableRlsSql(table, pkCols), schema)
|
|
47329
47737
|
};
|
|
47330
47738
|
await db.migrate([migration]);
|
|
47331
47739
|
}
|
|
@@ -47455,12 +47863,17 @@ function isRowAudience(audience) {
|
|
|
47455
47863
|
const a6 = (audience ?? "").trim();
|
|
47456
47864
|
return a6 === "" || a6 === "everyone" || a6 === "row-audience";
|
|
47457
47865
|
}
|
|
47458
|
-
function audiencePredicate(audience) {
|
|
47866
|
+
function audiencePredicate(audience, ctx) {
|
|
47459
47867
|
if (isRowAudience(audience)) return "true";
|
|
47460
47868
|
const clauses = audience.split("+").map((c6) => c6.trim()).filter(Boolean);
|
|
47461
47869
|
const parts = [];
|
|
47462
47870
|
for (const clause of clauses) {
|
|
47463
47871
|
if (clause === "everyone" || clause === "row-audience") return "true";
|
|
47872
|
+
if (clause === "owner") {
|
|
47873
|
+
if (!ctx) throw new Error('lattice: the "owner" audience needs a row context');
|
|
47874
|
+
parts.push(`lattice_is_owner(${ctx.tableLit}, ${ctx.pkExpr})`);
|
|
47875
|
+
continue;
|
|
47876
|
+
}
|
|
47464
47877
|
const idx = clause.indexOf(":");
|
|
47465
47878
|
const kind = idx === -1 ? clause : clause.slice(0, idx);
|
|
47466
47879
|
const arg = idx === -1 ? "" : clause.slice(idx + 1).trim();
|
|
@@ -47495,7 +47908,7 @@ function audienceViewSql(table, columns, pkCols, columnAudience) {
|
|
|
47495
47908
|
const selectCols = columns.map((col) => {
|
|
47496
47909
|
const aud = columnAudience[col] ?? "";
|
|
47497
47910
|
if (isRowAudience(aud)) return quoteIdent(col);
|
|
47498
|
-
const pred = audiencePredicate(aud);
|
|
47911
|
+
const pred = audiencePredicate(aud, { tableLit: lit, pkExpr });
|
|
47499
47912
|
if (pred === "true") return quoteIdent(col);
|
|
47500
47913
|
const colLit = `'${col.replace(/'/g, "''")}'`;
|
|
47501
47914
|
const full = `(${pred}) OR lattice_cell_visible(${lit}, ${pkExpr}, ${colLit})`;
|
|
@@ -47530,6 +47943,92 @@ async function enableAudienceView(db, table, columns, pkCols, columnAudience) {
|
|
|
47530
47943
|
};
|
|
47531
47944
|
await db.migrate([migration]);
|
|
47532
47945
|
}
|
|
47946
|
+
async function loadColumnPolicy(db, table) {
|
|
47947
|
+
if (db.getDialect() !== "postgres") return {};
|
|
47948
|
+
const rows = await allAsyncOrSync(
|
|
47949
|
+
db.adapter,
|
|
47950
|
+
`SELECT "column_name", "audience" FROM "__lattice_column_policy" WHERE "table_name" = ?`,
|
|
47951
|
+
[table]
|
|
47952
|
+
);
|
|
47953
|
+
const out = {};
|
|
47954
|
+
for (const r6 of rows) out[r6.column_name] = r6.audience;
|
|
47955
|
+
return out;
|
|
47956
|
+
}
|
|
47957
|
+
async function seedColumnPolicyFromYaml(db, table, yamlAudience) {
|
|
47958
|
+
if (db.getDialect() !== "postgres") return;
|
|
47959
|
+
const marker = `internal:cloud-column-seed:${table}:v1`;
|
|
47960
|
+
const already = await getAsyncOrSync(
|
|
47961
|
+
db.adapter,
|
|
47962
|
+
`SELECT 1 AS one FROM "__lattice_migrations" WHERE "version" = ?`,
|
|
47963
|
+
[marker]
|
|
47964
|
+
);
|
|
47965
|
+
if (already) return;
|
|
47966
|
+
for (const [col, aud] of Object.entries(yamlAudience)) {
|
|
47967
|
+
if (isRowAudience(aud)) continue;
|
|
47968
|
+
await runAsyncOrSync(
|
|
47969
|
+
db.adapter,
|
|
47970
|
+
`INSERT INTO "__lattice_column_policy" ("table_name","column_name","audience")
|
|
47971
|
+
VALUES (?, ?, ?) ON CONFLICT ("table_name","column_name") DO NOTHING`,
|
|
47972
|
+
[table, col, aud]
|
|
47973
|
+
);
|
|
47974
|
+
}
|
|
47975
|
+
await runAsyncOrSync(
|
|
47976
|
+
db.adapter,
|
|
47977
|
+
`INSERT INTO "__lattice_migrations" ("version","applied_at") VALUES (?, ?)
|
|
47978
|
+
ON CONFLICT ("version") DO NOTHING`,
|
|
47979
|
+
[marker, (/* @__PURE__ */ new Date()).toISOString()]
|
|
47980
|
+
);
|
|
47981
|
+
}
|
|
47982
|
+
async function regenerateAudienceViewFromDb(db, table, columns, pkCols) {
|
|
47983
|
+
if (db.getDialect() !== "postgres") return;
|
|
47984
|
+
if (pkCols.length === 0) return;
|
|
47985
|
+
const spec = await loadColumnPolicy(db, table);
|
|
47986
|
+
const view = quoteIdent(`${table}_v`);
|
|
47987
|
+
const base = quoteIdent(table);
|
|
47988
|
+
if (!tableNeedsAudienceView(spec)) {
|
|
47989
|
+
await runAsyncOrSync(
|
|
47990
|
+
db.adapter,
|
|
47991
|
+
`DROP VIEW IF EXISTS ${view};
|
|
47992
|
+
GRANT SELECT ON ${base} TO ${MEMBER_GROUP};`
|
|
47993
|
+
);
|
|
47994
|
+
return;
|
|
47995
|
+
}
|
|
47996
|
+
await runAsyncOrSync(db.adapter, audienceViewSql(table, columns, pkCols, spec));
|
|
47997
|
+
}
|
|
47998
|
+
async function setColumnAudience(db, table, column, audience, columns, pkCols) {
|
|
47999
|
+
if (db.getDialect() !== "postgres") return;
|
|
48000
|
+
await runAsyncOrSync(db.adapter, `SELECT lattice_set_column_audience(?, ?, ?)`, [
|
|
48001
|
+
table,
|
|
48002
|
+
column,
|
|
48003
|
+
audience
|
|
48004
|
+
]);
|
|
48005
|
+
await regenerateAudienceViewFromDb(db, table, columns, pkCols);
|
|
48006
|
+
}
|
|
48007
|
+
|
|
48008
|
+
// src/cloud/table-policy.ts
|
|
48009
|
+
async function getTablePolicy(db, table) {
|
|
48010
|
+
if (db.getDialect() !== "postgres") return { defaultRowVisibility: "private", neverShare: false };
|
|
48011
|
+
const row = await getAsyncOrSync(
|
|
48012
|
+
db.adapter,
|
|
48013
|
+
`SELECT "default_row_visibility", "never_share" FROM "__lattice_table_policy" WHERE "table_name" = ?`,
|
|
48014
|
+
[table]
|
|
48015
|
+
);
|
|
48016
|
+
return {
|
|
48017
|
+
defaultRowVisibility: row?.default_row_visibility === "everyone" ? "everyone" : "private",
|
|
48018
|
+
neverShare: row?.never_share === true
|
|
48019
|
+
};
|
|
48020
|
+
}
|
|
48021
|
+
async function setTableDefaultVisibility(db, table, visibility) {
|
|
48022
|
+
if (db.getDialect() !== "postgres") return;
|
|
48023
|
+
await runAsyncOrSync(db.adapter, `SELECT lattice_set_table_default_visibility(?, ?)`, [
|
|
48024
|
+
table,
|
|
48025
|
+
visibility
|
|
48026
|
+
]);
|
|
48027
|
+
}
|
|
48028
|
+
async function setTableNeverShare(db, table, on) {
|
|
48029
|
+
if (db.getDialect() !== "postgres") return;
|
|
48030
|
+
await runAsyncOrSync(db.adapter, `SELECT lattice_set_table_never_share(?, ?)`, [table, on]);
|
|
48031
|
+
}
|
|
47533
48032
|
|
|
47534
48033
|
// src/cloud/fold-cache.ts
|
|
47535
48034
|
function viewerSignature(viewer) {
|
|
@@ -47604,9 +48103,12 @@ END $fn$;
|
|
|
47604
48103
|
`;
|
|
47605
48104
|
async function installCloudSettings(db) {
|
|
47606
48105
|
if (db.getDialect() !== "postgres") return;
|
|
48106
|
+
const schema = await cloudSchema(db);
|
|
47607
48107
|
const migration = {
|
|
47608
|
-
|
|
47609
|
-
|
|
48108
|
+
// v2 pins search_path on the two SECURITY DEFINER helpers (closes the
|
|
48109
|
+
// pg_temp-shadow class of bypass on the settings getter/setter).
|
|
48110
|
+
version: "internal:cloud-settings:v2",
|
|
48111
|
+
sql: pinDefinerSearchPath(CLOUD_SETTINGS_BOOTSTRAP_SQL, schema)
|
|
47610
48112
|
};
|
|
47611
48113
|
await db.migrate([migration]);
|
|
47612
48114
|
}
|
|
@@ -47633,7 +48135,8 @@ async function secureCloud(db) {
|
|
|
47633
48135
|
await installCloudSettings(db);
|
|
47634
48136
|
await db.ensureObservationSubstrate();
|
|
47635
48137
|
await enableChangelogRls(db);
|
|
47636
|
-
|
|
48138
|
+
const registered = db.getRegisteredTableNames();
|
|
48139
|
+
for (const table of registered) {
|
|
47637
48140
|
if (table.startsWith("__lattice_") || table.startsWith("_lattice_")) continue;
|
|
47638
48141
|
const pk = db.getPrimaryKey(table);
|
|
47639
48142
|
if (pk.length === 0) continue;
|
|
@@ -47641,9 +48144,21 @@ async function secureCloud(db) {
|
|
|
47641
48144
|
await enableRlsForTable(db, table, pk);
|
|
47642
48145
|
const cols = db.getRegisteredColumns(table);
|
|
47643
48146
|
if (cols) {
|
|
47644
|
-
await
|
|
48147
|
+
await seedColumnPolicyFromYaml(db, table, db.getColumnAudience(table));
|
|
48148
|
+
await regenerateAudienceViewFromDb(db, table, Object.keys(cols), pk);
|
|
47645
48149
|
}
|
|
47646
48150
|
}
|
|
48151
|
+
if (registered.includes("secrets")) {
|
|
48152
|
+
await runAsyncOrSync(db.adapter, `SELECT lattice_set_table_never_share('secrets', true)`);
|
|
48153
|
+
}
|
|
48154
|
+
await runAsyncOrSync(
|
|
48155
|
+
db.adapter,
|
|
48156
|
+
`DO $LATTICE$ BEGIN
|
|
48157
|
+
IF to_regclass('__lattice_user_identity') IS NOT NULL THEN
|
|
48158
|
+
EXECUTE 'GRANT SELECT, INSERT, UPDATE ON "__lattice_user_identity" TO ${MEMBER_GROUP}';
|
|
48159
|
+
END IF;
|
|
48160
|
+
END $LATTICE$`
|
|
48161
|
+
);
|
|
47647
48162
|
}
|
|
47648
48163
|
|
|
47649
48164
|
// src/ai/llm-client.ts
|
|
@@ -48237,6 +48752,7 @@ export {
|
|
|
48237
48752
|
NATIVE_ENTITY_NAMES,
|
|
48238
48753
|
NATIVE_REGISTRY_TABLE,
|
|
48239
48754
|
PostgresAdapter,
|
|
48755
|
+
ProgressThrottle,
|
|
48240
48756
|
READ_ONLY_HEADER,
|
|
48241
48757
|
ROOT_DIRNAME,
|
|
48242
48758
|
ReferenceUnavailableError,
|
|
@@ -48300,6 +48816,7 @@ export {
|
|
|
48300
48816
|
getCloudSetting,
|
|
48301
48817
|
getDbCredential,
|
|
48302
48818
|
getOrCreateMasterKey,
|
|
48819
|
+
getTablePolicy,
|
|
48303
48820
|
getWorkspace,
|
|
48304
48821
|
grantCell,
|
|
48305
48822
|
hasFtsIndex,
|
|
@@ -48317,6 +48834,7 @@ export {
|
|
|
48317
48834
|
listNativeBindings,
|
|
48318
48835
|
listTokens,
|
|
48319
48836
|
listWorkspaces,
|
|
48837
|
+
loadColumnPolicy,
|
|
48320
48838
|
manifestPath,
|
|
48321
48839
|
markdownTable,
|
|
48322
48840
|
memberRoleName,
|
|
@@ -48344,6 +48862,7 @@ export {
|
|
|
48344
48862
|
readToken,
|
|
48345
48863
|
referenceLocalFile,
|
|
48346
48864
|
referenceUrl,
|
|
48865
|
+
regenerateAudienceViewFromDb,
|
|
48347
48866
|
registerNativeEntities,
|
|
48348
48867
|
registryPath,
|
|
48349
48868
|
resolveActiveS3Config,
|
|
@@ -48358,9 +48877,13 @@ export {
|
|
|
48358
48877
|
saveDbCredentialForTeam,
|
|
48359
48878
|
sealUnderSource,
|
|
48360
48879
|
secureCloud,
|
|
48880
|
+
seedColumnPolicyFromYaml,
|
|
48361
48881
|
setActiveWorkspace,
|
|
48362
48882
|
setCloudSetting,
|
|
48883
|
+
setColumnAudience,
|
|
48363
48884
|
setRowVisibility,
|
|
48885
|
+
setTableDefaultVisibility,
|
|
48886
|
+
setTableNeverShare,
|
|
48364
48887
|
shredSource,
|
|
48365
48888
|
slugify,
|
|
48366
48889
|
summarizeText,
|