latticesql 3.0.0 → 3.2.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/README.md +4 -0
- package/dist/cli.js +4234 -1767
- package/dist/index.cjs +850 -154
- package/dist/index.d.cts +228 -26
- package/dist/index.d.ts +228 -26
- package/dist/index.js +842 -154
- package/docs/api-reference.md +1370 -0
- package/docs/architecture.md +331 -0
- package/docs/assistant.md +138 -0
- package/docs/cli.md +515 -0
- package/docs/cloud.md +675 -0
- package/docs/collaboration.md +85 -0
- package/docs/configuration.md +416 -0
- package/docs/entity-context.md +510 -0
- package/docs/examples/agent-system.md +313 -0
- package/docs/examples/cms.md +366 -0
- package/docs/examples/ticket-tracker.md +313 -0
- package/docs/migrations.md +272 -0
- package/docs/templates.md +338 -0
- package/docs/workspaces.md +81 -0
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -41438,7 +41438,68 @@ 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
|
+
/**
|
|
41447
|
+
* Last passthrough time, keyed per table (`event.table`, or `''` for the
|
|
41448
|
+
* table-less `done`/`error` lifecycle events). Per-table — not a single shared
|
|
41449
|
+
* clock — so when tables render CONCURRENTLY each one keeps its own ~5/sec
|
|
41450
|
+
* budget: a fast table can't consume the window and starve a slow table's
|
|
41451
|
+
* progress. `force` (table-start) resets only that table's budget.
|
|
41452
|
+
*/
|
|
41453
|
+
lastEmit = /* @__PURE__ */ new Map();
|
|
41454
|
+
constructor(cb, windowMs = THROTTLE_WINDOW_MS) {
|
|
41455
|
+
this.cb = cb;
|
|
41456
|
+
this.windowMs = windowMs;
|
|
41457
|
+
}
|
|
41458
|
+
/**
|
|
41459
|
+
* Emit a `table-progress` event, but only if the window since this table's
|
|
41460
|
+
* last passthrough has elapsed. Dropped events are simply not delivered — the
|
|
41461
|
+
* next one that survives carries the latest running count.
|
|
41462
|
+
*/
|
|
41463
|
+
tick(event) {
|
|
41464
|
+
if (!this.cb) return;
|
|
41465
|
+
const key = event.table ?? "";
|
|
41466
|
+
const now = Date.now();
|
|
41467
|
+
if (now - (this.lastEmit.get(key) ?? 0) < this.windowMs) return;
|
|
41468
|
+
this.lastEmit.set(key, now);
|
|
41469
|
+
this.cb(event);
|
|
41470
|
+
}
|
|
41471
|
+
/**
|
|
41472
|
+
* Emit a lifecycle event immediately and reset this table's throttle window.
|
|
41473
|
+
* Use for `table-start`, `table-done`, `done`, and `error` — none of which
|
|
41474
|
+
* should ever be dropped. Resetting on `table-start` gives each table a clean
|
|
41475
|
+
* budget.
|
|
41476
|
+
*/
|
|
41477
|
+
force(event) {
|
|
41478
|
+
if (!this.cb) return;
|
|
41479
|
+
this.lastEmit.set(event.table ?? "", Date.now());
|
|
41480
|
+
this.cb(event);
|
|
41481
|
+
}
|
|
41482
|
+
};
|
|
41483
|
+
|
|
41484
|
+
// src/concurrency.ts
|
|
41485
|
+
async function mapWithConcurrency(items, limit, fn) {
|
|
41486
|
+
const results = new Array(items.length);
|
|
41487
|
+
let next = 0;
|
|
41488
|
+
const workerCount = Math.max(1, Math.min(limit, items.length));
|
|
41489
|
+
const workers = Array.from({ length: workerCount }, async () => {
|
|
41490
|
+
for (; ; ) {
|
|
41491
|
+
const i6 = next++;
|
|
41492
|
+
if (i6 >= items.length) break;
|
|
41493
|
+
results[i6] = await fn(items[i6], i6);
|
|
41494
|
+
}
|
|
41495
|
+
});
|
|
41496
|
+
await Promise.all(workers);
|
|
41497
|
+
return results;
|
|
41498
|
+
}
|
|
41499
|
+
|
|
41441
41500
|
// src/render/engine.ts
|
|
41501
|
+
var YIELD_EVERY_ENTITIES = 200;
|
|
41502
|
+
var RENDER_TABLE_CONCURRENCY = 4;
|
|
41442
41503
|
var NOOP_RENDER = () => "";
|
|
41443
41504
|
var RenderEngine = class {
|
|
41444
41505
|
_schema;
|
|
@@ -41452,11 +41513,14 @@ var RenderEngine = class {
|
|
|
41452
41513
|
this._getTaskContext = getTaskContext ?? (() => "");
|
|
41453
41514
|
this._skipEmpty = options?.skipEmpty ?? false;
|
|
41454
41515
|
}
|
|
41455
|
-
async render(outputDir) {
|
|
41516
|
+
async render(outputDir, opts = {}) {
|
|
41456
41517
|
const start = Date.now();
|
|
41457
41518
|
const filesWritten = [];
|
|
41458
41519
|
const counters = { skipped: 0 };
|
|
41520
|
+
const signal = opts.signal;
|
|
41521
|
+
const throttle = new ProgressThrottle(opts.onProgress);
|
|
41459
41522
|
for (const [name, def] of this._schema.getTables()) {
|
|
41523
|
+
if (signal?.aborted) return this._abortedResult(filesWritten, counters, start);
|
|
41460
41524
|
if (this._skipEmpty && def.render === NOOP_RENDER) continue;
|
|
41461
41525
|
let rows = await this._schema.queryTable(this._adapter, name);
|
|
41462
41526
|
if (def.relevanceFilter) {
|
|
@@ -41498,8 +41562,18 @@ var RenderEngine = class {
|
|
|
41498
41562
|
} else {
|
|
41499
41563
|
counters.skipped++;
|
|
41500
41564
|
}
|
|
41565
|
+
throttle.force({
|
|
41566
|
+
kind: "table-done",
|
|
41567
|
+
table: name,
|
|
41568
|
+
entitiesRendered: rows.length,
|
|
41569
|
+
entitiesTotal: rows.length,
|
|
41570
|
+
tableIndex: 0,
|
|
41571
|
+
tableCount: 0,
|
|
41572
|
+
pct: 100
|
|
41573
|
+
});
|
|
41501
41574
|
}
|
|
41502
|
-
for (const [, def] of this._schema.getMultis()) {
|
|
41575
|
+
for (const [name, def] of this._schema.getMultis()) {
|
|
41576
|
+
if (signal?.aborted) return this._abortedResult(filesWritten, counters, start);
|
|
41503
41577
|
const keys = await def.keys();
|
|
41504
41578
|
const tables = {};
|
|
41505
41579
|
if (def.tables) {
|
|
@@ -41516,12 +41590,26 @@ var RenderEngine = class {
|
|
|
41516
41590
|
counters.skipped++;
|
|
41517
41591
|
}
|
|
41518
41592
|
}
|
|
41593
|
+
throttle.force({
|
|
41594
|
+
kind: "table-done",
|
|
41595
|
+
table: name,
|
|
41596
|
+
entitiesRendered: keys.length,
|
|
41597
|
+
entitiesTotal: keys.length,
|
|
41598
|
+
tableIndex: 0,
|
|
41599
|
+
tableCount: 0,
|
|
41600
|
+
pct: 100
|
|
41601
|
+
});
|
|
41519
41602
|
}
|
|
41520
41603
|
const entityContextManifest = await this._renderEntityContexts(
|
|
41521
41604
|
outputDir,
|
|
41522
41605
|
filesWritten,
|
|
41523
|
-
counters
|
|
41606
|
+
counters,
|
|
41607
|
+
throttle,
|
|
41608
|
+
signal
|
|
41524
41609
|
);
|
|
41610
|
+
if (entityContextManifest === null) {
|
|
41611
|
+
return this._abortedResult(filesWritten, counters, start);
|
|
41612
|
+
}
|
|
41525
41613
|
if (this._schema.getEntityContexts().size > 0) {
|
|
41526
41614
|
writeManifest(outputDir, {
|
|
41527
41615
|
version: 2,
|
|
@@ -41529,6 +41617,29 @@ var RenderEngine = class {
|
|
|
41529
41617
|
entityContexts: entityContextManifest
|
|
41530
41618
|
});
|
|
41531
41619
|
}
|
|
41620
|
+
const result = {
|
|
41621
|
+
filesWritten,
|
|
41622
|
+
filesSkipped: counters.skipped,
|
|
41623
|
+
durationMs: Date.now() - start
|
|
41624
|
+
};
|
|
41625
|
+
throttle.force({
|
|
41626
|
+
kind: "done",
|
|
41627
|
+
table: null,
|
|
41628
|
+
entitiesRendered: 0,
|
|
41629
|
+
entitiesTotal: 0,
|
|
41630
|
+
tableIndex: 0,
|
|
41631
|
+
tableCount: 0,
|
|
41632
|
+
pct: 100,
|
|
41633
|
+
durationMs: result.durationMs
|
|
41634
|
+
});
|
|
41635
|
+
return result;
|
|
41636
|
+
}
|
|
41637
|
+
/**
|
|
41638
|
+
* Build the partial RenderResult to return when a render is aborted. No
|
|
41639
|
+
* `done` event is emitted — the caller treats abort as "discard the partial
|
|
41640
|
+
* tree", not as a successful completion.
|
|
41641
|
+
*/
|
|
41642
|
+
_abortedResult(filesWritten, counters, start) {
|
|
41532
41643
|
return {
|
|
41533
41644
|
filesWritten,
|
|
41534
41645
|
filesSkipped: counters.skipped,
|
|
@@ -41564,127 +41675,181 @@ var RenderEngine = class {
|
|
|
41564
41675
|
/**
|
|
41565
41676
|
* Render all entity context definitions.
|
|
41566
41677
|
* Mutates `filesWritten` and `counters` in place.
|
|
41567
|
-
* Returns manifest data for the entity contexts rendered this cycle
|
|
41678
|
+
* Returns manifest data for the entity contexts rendered this cycle, or
|
|
41679
|
+
* `null` if the render was aborted mid-flight (the caller discards the
|
|
41680
|
+
* partial tree). Progress is reported through `throttle`; abort is observed
|
|
41681
|
+
* via `signal`.
|
|
41568
41682
|
*/
|
|
41569
|
-
async _renderEntityContexts(outputDir, filesWritten, counters) {
|
|
41570
|
-
const manifestData = {};
|
|
41683
|
+
async _renderEntityContexts(outputDir, filesWritten, counters, throttle, signal) {
|
|
41571
41684
|
const protectedTables = /* @__PURE__ */ new Set();
|
|
41572
41685
|
for (const [t8, d6] of this._schema.getEntityContexts()) {
|
|
41573
41686
|
if (d6.protected) protectedTables.add(t8);
|
|
41574
41687
|
}
|
|
41575
|
-
|
|
41576
|
-
|
|
41577
|
-
|
|
41578
|
-
|
|
41579
|
-
|
|
41580
|
-
|
|
41581
|
-
|
|
41582
|
-
|
|
41583
|
-
|
|
41584
|
-
|
|
41585
|
-
|
|
41586
|
-
|
|
41587
|
-
|
|
41588
|
-
|
|
41589
|
-
|
|
41590
|
-
|
|
41591
|
-
|
|
41592
|
-
|
|
41593
|
-
|
|
41594
|
-
|
|
41595
|
-
|
|
41596
|
-
const
|
|
41597
|
-
|
|
41598
|
-
|
|
41599
|
-
|
|
41600
|
-
|
|
41601
|
-
|
|
41602
|
-
|
|
41603
|
-
if (
|
|
41604
|
-
|
|
41688
|
+
const entityTables = [...this._schema.getEntityContexts()];
|
|
41689
|
+
const tableCount = entityTables.length;
|
|
41690
|
+
if (signal?.aborted) return null;
|
|
41691
|
+
const renderedEntries = await mapWithConcurrency(
|
|
41692
|
+
entityTables,
|
|
41693
|
+
RENDER_TABLE_CONCURRENCY,
|
|
41694
|
+
async ([table, def], tableIndex) => {
|
|
41695
|
+
if (signal?.aborted) return null;
|
|
41696
|
+
const entityPk = this._schema.getPrimaryKey(table)[0] ?? "id";
|
|
41697
|
+
const allRows = await this._schema.queryTable(this._adapter, table);
|
|
41698
|
+
const directoryRoot = def.directoryRoot ?? table;
|
|
41699
|
+
const entitiesTotal = allRows.length;
|
|
41700
|
+
throttle.force({
|
|
41701
|
+
kind: "table-start",
|
|
41702
|
+
table,
|
|
41703
|
+
entitiesRendered: 0,
|
|
41704
|
+
entitiesTotal,
|
|
41705
|
+
tableIndex,
|
|
41706
|
+
tableCount,
|
|
41707
|
+
pct: 0
|
|
41708
|
+
});
|
|
41709
|
+
const manifestEntry = {
|
|
41710
|
+
directoryRoot,
|
|
41711
|
+
...def.index ? { indexFile: def.index.outputFile } : {},
|
|
41712
|
+
declaredFiles: Object.keys(def.files),
|
|
41713
|
+
protectedFiles: def.protectedFiles ?? [],
|
|
41714
|
+
entities: {}
|
|
41715
|
+
};
|
|
41716
|
+
if (def.index) {
|
|
41717
|
+
const indexPath = join4(outputDir, def.index.outputFile);
|
|
41718
|
+
if (atomicWrite(indexPath, def.index.render(allRows))) {
|
|
41719
|
+
filesWritten.push(indexPath);
|
|
41720
|
+
} else {
|
|
41721
|
+
counters.skipped++;
|
|
41722
|
+
}
|
|
41605
41723
|
}
|
|
41606
|
-
|
|
41607
|
-
|
|
41608
|
-
|
|
41609
|
-
if (
|
|
41610
|
-
|
|
41611
|
-
|
|
41612
|
-
|
|
41613
|
-
|
|
41724
|
+
for (let i6 = 0; i6 < allRows.length; i6++) {
|
|
41725
|
+
const entityRow = allRows[i6];
|
|
41726
|
+
if (signal?.aborted) return null;
|
|
41727
|
+
if (i6 > 0 && i6 % YIELD_EVERY_ENTITIES === 0) {
|
|
41728
|
+
await new Promise((r6) => setImmediate(r6));
|
|
41729
|
+
}
|
|
41730
|
+
const rawSlug = def.slug(entityRow);
|
|
41731
|
+
const slug = rawSlug.replace(/[\u00A0\u2000-\u200B\u202F\u205F\u3000]/g, " ").replace(/[\u0000-\u001F\u007F]/g, "");
|
|
41732
|
+
if (/[^a-zA-Z0-9.\-_ @(),#&'+:;!~[\]]/.test(slug)) {
|
|
41733
|
+
throw new Error(`Invalid slug "${slug}": contains characters outside the allowed set`);
|
|
41734
|
+
}
|
|
41735
|
+
const entityDir = def.directory ? join4(outputDir, def.directory(entityRow)) : join4(outputDir, directoryRoot, slug);
|
|
41736
|
+
const resolvedDir = resolve(entityDir);
|
|
41737
|
+
const resolvedBase = resolve(outputDir);
|
|
41738
|
+
if (!resolvedDir.startsWith(resolvedBase + sep) && resolvedDir !== resolvedBase) {
|
|
41739
|
+
throw new Error(`Path traversal detected: slug "${slug}" escapes output directory`);
|
|
41740
|
+
}
|
|
41741
|
+
mkdirSync2(entityDir, { recursive: true });
|
|
41742
|
+
if (def.attachFileColumn) {
|
|
41743
|
+
const filePath = entityRow[def.attachFileColumn];
|
|
41744
|
+
if (filePath && typeof filePath === "string" && filePath.length > 0) {
|
|
41745
|
+
if (def.attachFileMode === "reference") {
|
|
41746
|
+
const refPath = join4(entityDir, `${basename(filePath)}.ref.md`);
|
|
41747
|
+
try {
|
|
41748
|
+
atomicWrite(refPath, `# Reference
|
|
41614
41749
|
|
|
41615
41750
|
- **location:** ${filePath}
|
|
41616
41751
|
`);
|
|
41617
|
-
|
|
41618
|
-
|
|
41619
|
-
|
|
41620
|
-
|
|
41621
|
-
|
|
41622
|
-
|
|
41623
|
-
|
|
41624
|
-
|
|
41625
|
-
|
|
41626
|
-
|
|
41627
|
-
|
|
41628
|
-
|
|
41752
|
+
filesWritten.push(refPath);
|
|
41753
|
+
} catch {
|
|
41754
|
+
}
|
|
41755
|
+
} else {
|
|
41756
|
+
const absPath = isAbsolute(filePath) ? filePath : resolve(outputDir, filePath);
|
|
41757
|
+
if (existsSync4(absPath)) {
|
|
41758
|
+
const destPath = join4(entityDir, basename(absPath));
|
|
41759
|
+
if (!existsSync4(destPath)) {
|
|
41760
|
+
try {
|
|
41761
|
+
copyFileSync2(absPath, destPath);
|
|
41762
|
+
filesWritten.push(destPath);
|
|
41763
|
+
} catch {
|
|
41764
|
+
}
|
|
41629
41765
|
}
|
|
41630
41766
|
}
|
|
41631
41767
|
}
|
|
41632
41768
|
}
|
|
41633
41769
|
}
|
|
41634
|
-
|
|
41635
|
-
|
|
41636
|
-
|
|
41637
|
-
|
|
41638
|
-
|
|
41639
|
-
|
|
41640
|
-
|
|
41641
|
-
|
|
41642
|
-
|
|
41643
|
-
|
|
41644
|
-
|
|
41645
|
-
|
|
41646
|
-
|
|
41647
|
-
|
|
41648
|
-
|
|
41649
|
-
|
|
41650
|
-
|
|
41651
|
-
|
|
41652
|
-
|
|
41653
|
-
|
|
41654
|
-
|
|
41655
|
-
|
|
41656
|
-
} else {
|
|
41657
|
-
counters.skipped++;
|
|
41658
|
-
}
|
|
41659
|
-
}
|
|
41660
|
-
const fileKeys = Object.keys(def.files);
|
|
41661
|
-
const effectiveCombined = def.combined ?? (fileKeys.length > 1 && renderedFiles.size > 1 ? (
|
|
41662
|
-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
41663
|
-
{ outputFile: fileKeys[0] }
|
|
41664
|
-
) : void 0);
|
|
41665
|
-
if (effectiveCombined && renderedFiles.size > 0) {
|
|
41666
|
-
const excluded = new Set(effectiveCombined.exclude ?? []);
|
|
41667
|
-
const parts = [];
|
|
41668
|
-
for (const filename of Object.keys(def.files)) {
|
|
41669
|
-
if (!excluded.has(filename) && renderedFiles.has(filename)) {
|
|
41670
|
-
parts.push(renderedFiles.get(filename) ?? "");
|
|
41671
|
-
}
|
|
41672
|
-
}
|
|
41673
|
-
if (parts.length > 0) {
|
|
41674
|
-
const combinedContent = parts.join("\n\n---\n\n");
|
|
41675
|
-
const combinedPath = join4(entityDir, effectiveCombined.outputFile);
|
|
41676
|
-
if (atomicWrite(combinedPath, combinedContent)) {
|
|
41677
|
-
filesWritten.push(combinedPath);
|
|
41770
|
+
const renderedFiles = /* @__PURE__ */ new Map();
|
|
41771
|
+
const entityFileHashes = {};
|
|
41772
|
+
const protection = protectedTables.size > 0 ? { protectedTables, currentTable: table } : void 0;
|
|
41773
|
+
for (const [filename, spec] of Object.entries(def.files)) {
|
|
41774
|
+
if (signal?.aborted) return null;
|
|
41775
|
+
const mergeDefaults = def.sourceDefaults && spec.source.type !== "self" && spec.source.type !== "custom" && spec.source.type !== "enriched";
|
|
41776
|
+
const source = mergeDefaults ? { ...def.sourceDefaults, ...spec.source } : spec.source;
|
|
41777
|
+
const rows = await resolveEntitySource(
|
|
41778
|
+
source,
|
|
41779
|
+
entityRow,
|
|
41780
|
+
entityPk,
|
|
41781
|
+
this._adapter,
|
|
41782
|
+
protection
|
|
41783
|
+
);
|
|
41784
|
+
if (spec.omitIfEmpty && rows.length === 0) continue;
|
|
41785
|
+
const renderFn = compileEntityRender(spec.render);
|
|
41786
|
+
const content = truncateContent(renderFn(rows), spec.budget);
|
|
41787
|
+
renderedFiles.set(filename, content);
|
|
41788
|
+
entityFileHashes[filename] = { hash: contentHash(content) };
|
|
41789
|
+
const filePath = join4(entityDir, filename);
|
|
41790
|
+
if (atomicWrite(filePath, content)) {
|
|
41791
|
+
filesWritten.push(filePath);
|
|
41678
41792
|
} else {
|
|
41679
41793
|
counters.skipped++;
|
|
41680
41794
|
}
|
|
41681
|
-
renderedFiles.set(effectiveCombined.outputFile, combinedContent);
|
|
41682
|
-
entityFileHashes[effectiveCombined.outputFile] = { hash: contentHash(combinedContent) };
|
|
41683
41795
|
}
|
|
41796
|
+
const fileKeys = Object.keys(def.files);
|
|
41797
|
+
const effectiveCombined = def.combined ?? (fileKeys.length > 1 && renderedFiles.size > 1 ? (
|
|
41798
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
41799
|
+
{ outputFile: fileKeys[0] }
|
|
41800
|
+
) : void 0);
|
|
41801
|
+
if (effectiveCombined && renderedFiles.size > 0) {
|
|
41802
|
+
const excluded = new Set(effectiveCombined.exclude ?? []);
|
|
41803
|
+
const parts = [];
|
|
41804
|
+
for (const filename of Object.keys(def.files)) {
|
|
41805
|
+
if (!excluded.has(filename) && renderedFiles.has(filename)) {
|
|
41806
|
+
parts.push(renderedFiles.get(filename) ?? "");
|
|
41807
|
+
}
|
|
41808
|
+
}
|
|
41809
|
+
if (parts.length > 0) {
|
|
41810
|
+
const combinedContent = parts.join("\n\n---\n\n");
|
|
41811
|
+
const combinedPath = join4(entityDir, effectiveCombined.outputFile);
|
|
41812
|
+
if (atomicWrite(combinedPath, combinedContent)) {
|
|
41813
|
+
filesWritten.push(combinedPath);
|
|
41814
|
+
} else {
|
|
41815
|
+
counters.skipped++;
|
|
41816
|
+
}
|
|
41817
|
+
renderedFiles.set(effectiveCombined.outputFile, combinedContent);
|
|
41818
|
+
entityFileHashes[effectiveCombined.outputFile] = {
|
|
41819
|
+
hash: contentHash(combinedContent)
|
|
41820
|
+
};
|
|
41821
|
+
}
|
|
41822
|
+
}
|
|
41823
|
+
manifestEntry.entities[slug] = entityFileHashes;
|
|
41824
|
+
const entitiesRendered = i6 + 1;
|
|
41825
|
+
throttle.tick({
|
|
41826
|
+
kind: "table-progress",
|
|
41827
|
+
table,
|
|
41828
|
+
entitiesRendered,
|
|
41829
|
+
entitiesTotal,
|
|
41830
|
+
tableIndex,
|
|
41831
|
+
tableCount,
|
|
41832
|
+
pct: entitiesTotal > 0 ? entitiesRendered / entitiesTotal * 100 : 100
|
|
41833
|
+
});
|
|
41684
41834
|
}
|
|
41685
|
-
|
|
41835
|
+
throttle.force({
|
|
41836
|
+
kind: "table-done",
|
|
41837
|
+
table,
|
|
41838
|
+
entitiesRendered: entitiesTotal,
|
|
41839
|
+
entitiesTotal,
|
|
41840
|
+
tableIndex,
|
|
41841
|
+
tableCount,
|
|
41842
|
+
pct: 100
|
|
41843
|
+
});
|
|
41844
|
+
return manifestEntry;
|
|
41686
41845
|
}
|
|
41687
|
-
|
|
41846
|
+
);
|
|
41847
|
+
if (signal?.aborted) return null;
|
|
41848
|
+
const manifestData = {};
|
|
41849
|
+
for (let i6 = 0; i6 < renderedEntries.length; i6++) {
|
|
41850
|
+
const entry = renderedEntries[i6];
|
|
41851
|
+
if (entry == null) return null;
|
|
41852
|
+
manifestData[entityTables[i6][0]] = entry;
|
|
41688
41853
|
}
|
|
41689
41854
|
return manifestData;
|
|
41690
41855
|
}
|
|
@@ -42944,14 +43109,27 @@ function buildParsedConfig(raw, sourceName, configDir2) {
|
|
|
42944
43109
|
const entityContexts = parseEntityContexts(config.entityContexts);
|
|
42945
43110
|
return name !== void 0 ? { dbPath, name, tables, entityContexts } : { dbPath, tables, entityContexts };
|
|
42946
43111
|
}
|
|
43112
|
+
function isDbRefShaped(raw) {
|
|
43113
|
+
return /^\s*\$\{LATTICE_DB:/.test(raw);
|
|
43114
|
+
}
|
|
43115
|
+
function parseDbRef(raw) {
|
|
43116
|
+
const m4 = /^\$\{LATTICE_DB:([A-Za-z0-9._-]+)\}$/.exec(raw.trim());
|
|
43117
|
+
return m4 ? { label: m4[1] ?? "" } : null;
|
|
43118
|
+
}
|
|
42947
43119
|
function resolveDbPath(raw, configDir2) {
|
|
42948
|
-
|
|
42949
|
-
|
|
42950
|
-
|
|
42951
|
-
|
|
43120
|
+
if (isDbRefShaped(raw)) {
|
|
43121
|
+
const ref = parseDbRef(raw);
|
|
43122
|
+
if (!ref) {
|
|
43123
|
+
throw new Error(
|
|
43124
|
+
`Lattice: malformed \${LATTICE_DB:\u2026} reference ${JSON.stringify(
|
|
43125
|
+
raw.trim()
|
|
43126
|
+
)} \u2014 the label may contain only [A-Za-z0-9._-] (no spaces). This usually means a workspace was created with an unsanitized name.`
|
|
43127
|
+
);
|
|
43128
|
+
}
|
|
43129
|
+
const url = getDbCredential(ref.label);
|
|
42952
43130
|
if (!url) {
|
|
42953
43131
|
throw new Error(
|
|
42954
|
-
`Lattice: config references \${LATTICE_DB:${label}} but no credential is saved for "${label}". Save one via the GUI's Database panel or set LATTICE_DB_${label}.`
|
|
43132
|
+
`Lattice: config references \${LATTICE_DB:${ref.label}} but no credential is saved for "${ref.label}". Save one via the GUI's Database panel or set LATTICE_DB_${ref.label}.`
|
|
42955
43133
|
);
|
|
42956
43134
|
}
|
|
42957
43135
|
return url;
|
|
@@ -42959,6 +43137,13 @@ function resolveDbPath(raw, configDir2) {
|
|
|
42959
43137
|
if (/^postgres(ql)?:\/\//i.test(raw) || raw.startsWith("file:") || raw === ":memory:") {
|
|
42960
43138
|
return raw;
|
|
42961
43139
|
}
|
|
43140
|
+
if (raw.includes("${")) {
|
|
43141
|
+
throw new Error(
|
|
43142
|
+
`Lattice: refusing to treat ${JSON.stringify(
|
|
43143
|
+
raw.trim()
|
|
43144
|
+
)} as a database path \u2014 it looks like a malformed variable reference, not a file path.`
|
|
43145
|
+
);
|
|
43146
|
+
}
|
|
42962
43147
|
return resolve2(configDir2, raw);
|
|
42963
43148
|
}
|
|
42964
43149
|
var warnedDeprecatedRefs = /* @__PURE__ */ new Set();
|
|
@@ -44291,6 +44476,54 @@ var Lattice = class _Lattice {
|
|
|
44291
44476
|
async insert(table, row, provenance) {
|
|
44292
44477
|
const notInit = this._notInitError();
|
|
44293
44478
|
if (notInit) return notInit;
|
|
44479
|
+
const { sql, values, pkValue, rowWithPk } = this._prepareInsert(table, row);
|
|
44480
|
+
await runAsyncOrSync(this._adapter, sql, values);
|
|
44481
|
+
await this._afterInsert(table, pkValue, rowWithPk, provenance);
|
|
44482
|
+
return pkValue;
|
|
44483
|
+
}
|
|
44484
|
+
/**
|
|
44485
|
+
* Insert a row while atomically forcing its cloud row-visibility, regardless of
|
|
44486
|
+
* the table's `default_row_visibility`. The per-table insert trigger reads a
|
|
44487
|
+
* transaction-local GUC (`lattice.force_row_visibility`); we set it and run the
|
|
44488
|
+
* INSERT inside a single transaction, so the row is stamped at `visibility` the
|
|
44489
|
+
* instant it exists — it is never momentarily visible at the table default, and
|
|
44490
|
+
* the change-feed `NOTIFY` (delivered only at COMMIT) fires when the row already
|
|
44491
|
+
* carries this visibility. This closes the create-then-demote window that a
|
|
44492
|
+
* plain `insert()` + `setRowVisibility()` would leave open.
|
|
44493
|
+
*
|
|
44494
|
+
* Postgres-only: SQLite is single-user (no cross-viewer leak) and has no trigger
|
|
44495
|
+
* to read the GUC, so it degrades to a plain {@link insert}. A `never_share`
|
|
44496
|
+
* table still wins — its rows are forced private even if `visibility` is
|
|
44497
|
+
* `'everyone'` (the trigger enforces that precedence).
|
|
44498
|
+
*
|
|
44499
|
+
* @since 3.1.0
|
|
44500
|
+
*/
|
|
44501
|
+
async insertForcingVisibility(table, row, visibility, provenance) {
|
|
44502
|
+
const notInit = this._notInitError();
|
|
44503
|
+
if (notInit) return notInit;
|
|
44504
|
+
const vis = visibility;
|
|
44505
|
+
if (vis !== "private" && vis !== "everyone") {
|
|
44506
|
+
throw new Error(`lattice: invalid forced visibility "${vis}"`);
|
|
44507
|
+
}
|
|
44508
|
+
const withClient = this._adapter.withClient?.bind(this._adapter);
|
|
44509
|
+
if (this.getDialect() !== "postgres" || !withClient) {
|
|
44510
|
+
return this.insert(table, row, provenance);
|
|
44511
|
+
}
|
|
44512
|
+
const { sql, values, pkValue, rowWithPk } = this._prepareInsert(table, row);
|
|
44513
|
+
await withClient(async (tx) => {
|
|
44514
|
+
await tx.run(`SELECT set_config('lattice.force_row_visibility', ?, true)`, [visibility]);
|
|
44515
|
+
await tx.run(sql, values);
|
|
44516
|
+
});
|
|
44517
|
+
await this._afterInsert(table, pkValue, rowWithPk, provenance);
|
|
44518
|
+
return pkValue;
|
|
44519
|
+
}
|
|
44520
|
+
/**
|
|
44521
|
+
* Build the INSERT statement + canonical pk for a row (sanitize → schema-filter →
|
|
44522
|
+
* auto-pk → encrypt). Shared by {@link insert} and {@link insertForcingVisibility}
|
|
44523
|
+
* so both produce byte-identical writes; the latter only differs in running it
|
|
44524
|
+
* inside a GUC-scoped transaction.
|
|
44525
|
+
*/
|
|
44526
|
+
_prepareInsert(table, row) {
|
|
44294
44527
|
this._assertIdent(table);
|
|
44295
44528
|
const sanitized = this._filterToSchemaColumns(table, this._sanitizer.sanitizeRow(row));
|
|
44296
44529
|
const pkCols = this._schema.getPrimaryKey(table);
|
|
@@ -44306,12 +44539,17 @@ var Lattice = class _Lattice {
|
|
|
44306
44539
|
const cols = Object.keys(encrypted).map((c6) => `"${c6}"`).join(", ");
|
|
44307
44540
|
const placeholders = Object.keys(encrypted).map(() => "?").join(", ");
|
|
44308
44541
|
const values = Object.values(encrypted);
|
|
44309
|
-
await runAsyncOrSync(
|
|
44310
|
-
this._adapter,
|
|
44311
|
-
`INSERT INTO "${table}" (${cols}) VALUES (${placeholders})`,
|
|
44312
|
-
values
|
|
44313
|
-
);
|
|
44314
44542
|
const pkValue = this._serializeRowPk(table, rowWithPk);
|
|
44543
|
+
return {
|
|
44544
|
+
sql: `INSERT INTO "${table}" (${cols}) VALUES (${placeholders})`,
|
|
44545
|
+
values,
|
|
44546
|
+
pkValue,
|
|
44547
|
+
rowWithPk
|
|
44548
|
+
};
|
|
44549
|
+
}
|
|
44550
|
+
/** Post-insert side effects (changelog, audit, write hooks, embedding sync),
|
|
44551
|
+
* identical for the plain and force-visibility insert paths. */
|
|
44552
|
+
async _afterInsert(table, pkValue, rowWithPk, provenance) {
|
|
44315
44553
|
await this._appendChangelog(
|
|
44316
44554
|
table,
|
|
44317
44555
|
pkValue,
|
|
@@ -44325,7 +44563,6 @@ var Lattice = class _Lattice {
|
|
|
44325
44563
|
this._sanitizer.emitAudit(table, "insert", pkValue);
|
|
44326
44564
|
await this._fireWriteHooks(table, "insert", rowWithPk, pkValue);
|
|
44327
44565
|
this._syncEmbedding(table, "insert", rowWithPk, pkValue);
|
|
44328
|
-
return pkValue;
|
|
44329
44566
|
}
|
|
44330
44567
|
/**
|
|
44331
44568
|
* Insert a row and return the full inserted row (including auto-generated
|
|
@@ -44392,6 +44629,7 @@ var Lattice = class _Lattice {
|
|
|
44392
44629
|
const sanitized = this._filterToSchemaColumns(table, this._sanitizer.sanitizeRow(row));
|
|
44393
44630
|
const encrypted = this._encryptRow(table, sanitized);
|
|
44394
44631
|
const setCols = Object.keys(encrypted).map((c6) => `"${c6}" = ?`).join(", ");
|
|
44632
|
+
if (setCols === "") return;
|
|
44395
44633
|
const { clause, params: pkParams } = this._pkWhere(table, id);
|
|
44396
44634
|
let previousValues = null;
|
|
44397
44635
|
if (this._changelogTables.has(table)) {
|
|
@@ -45008,13 +45246,32 @@ var Lattice = class _Lattice {
|
|
|
45008
45246
|
// -------------------------------------------------------------------------
|
|
45009
45247
|
// Sync
|
|
45010
45248
|
// -------------------------------------------------------------------------
|
|
45011
|
-
async render(outputDir) {
|
|
45249
|
+
async render(outputDir, opts = {}) {
|
|
45012
45250
|
const notInit = this._notInitError();
|
|
45013
45251
|
if (notInit) return notInit;
|
|
45014
|
-
const result = await this._render.render(outputDir);
|
|
45252
|
+
const result = await this._render.render(outputDir, opts);
|
|
45015
45253
|
for (const h6 of this._renderHandlers) h6(result);
|
|
45016
45254
|
return result;
|
|
45017
45255
|
}
|
|
45256
|
+
/**
|
|
45257
|
+
* Render into `outputDir` through the shared single-flight guard, intended to
|
|
45258
|
+
* be called fire-and-forget (e.g. the GUI's instant-open background render).
|
|
45259
|
+
*
|
|
45260
|
+
* The guard ({@link _renderGuarded}) holds {@link _autoRenderInFlight} for the
|
|
45261
|
+
* render's duration, so a data mutation that lands while this render is in
|
|
45262
|
+
* flight is deferred by {@link _runAutoRender} and coalesced — when this
|
|
45263
|
+
* render settles, `finally` clears the flag and re-arms exactly one follow-up
|
|
45264
|
+
* render via {@link _rearmAutoRenderIfPending}. Net invariant: at most one
|
|
45265
|
+
* render to a given dir at a time.
|
|
45266
|
+
*
|
|
45267
|
+
* Errors propagate to the caller (the GUI surfaces them, never silently swallowed); they are
|
|
45268
|
+
* not swallowed here.
|
|
45269
|
+
*/
|
|
45270
|
+
async renderInBackground(outputDir, opts = {}) {
|
|
45271
|
+
const notInit = this._notInitError();
|
|
45272
|
+
if (notInit) return notInit;
|
|
45273
|
+
return this._renderGuarded(outputDir, opts);
|
|
45274
|
+
}
|
|
45018
45275
|
async sync(outputDir) {
|
|
45019
45276
|
const notInit = this._notInitError();
|
|
45020
45277
|
if (notInit) return notInit;
|
|
@@ -45356,6 +45613,30 @@ var Lattice = class _Lattice {
|
|
|
45356
45613
|
}, this._autoRenderDebounceMs);
|
|
45357
45614
|
this._autoRenderTimer.unref();
|
|
45358
45615
|
}
|
|
45616
|
+
/**
|
|
45617
|
+
* Shared single-flight render path used by {@link renderInBackground}.
|
|
45618
|
+
*
|
|
45619
|
+
* Holds {@link _autoRenderInFlight} for the render's duration so the
|
|
45620
|
+
* mutation-driven {@link _runAutoRender} defers while this render runs (it
|
|
45621
|
+
* sees the flag and marks itself pending instead of starting a second,
|
|
45622
|
+
* overlapping render). On settle, `finally` clears the flag and re-arms a
|
|
45623
|
+
* single coalesced follow-up render if any mutation arrived mid-flight.
|
|
45624
|
+
* Errors propagate to the caller; the flag is always cleared.
|
|
45625
|
+
*/
|
|
45626
|
+
async _renderGuarded(outputDir, opts) {
|
|
45627
|
+
while (this._autoRenderInFlight) {
|
|
45628
|
+
await new Promise((r6) => setImmediate(r6));
|
|
45629
|
+
}
|
|
45630
|
+
this._autoRenderInFlight = true;
|
|
45631
|
+
try {
|
|
45632
|
+
const result = await this._render.render(outputDir, opts);
|
|
45633
|
+
for (const h6 of this._renderHandlers) h6(result);
|
|
45634
|
+
return result;
|
|
45635
|
+
} finally {
|
|
45636
|
+
this._autoRenderInFlight = false;
|
|
45637
|
+
this._rearmAutoRenderIfPending();
|
|
45638
|
+
}
|
|
45639
|
+
}
|
|
45359
45640
|
async _runAutoRender() {
|
|
45360
45641
|
const dir = this._autoRenderDir;
|
|
45361
45642
|
if (!dir || !this._initialized) return;
|
|
@@ -46916,7 +47197,7 @@ function archiveLocalSqlite(dbPath) {
|
|
|
46916
47197
|
async function cloudRlsInstalled(probe) {
|
|
46917
47198
|
const row = await getAsyncOrSync(
|
|
46918
47199
|
probe.adapter,
|
|
46919
|
-
`SELECT to_regclass('
|
|
47200
|
+
`SELECT to_regclass('__lattice_owners') AS reg`
|
|
46920
47201
|
);
|
|
46921
47202
|
return !!row && row.reg != null;
|
|
46922
47203
|
}
|
|
@@ -46973,6 +47254,19 @@ function isPostgresUrl(url) {
|
|
|
46973
47254
|
}
|
|
46974
47255
|
|
|
46975
47256
|
// src/cloud/rls.ts
|
|
47257
|
+
async function runCloudBootstrapSql(db, sql) {
|
|
47258
|
+
const adapter = db.adapter;
|
|
47259
|
+
if (adapter.withClient) {
|
|
47260
|
+
await adapter.withClient(async (tx) => {
|
|
47261
|
+
await tx.run("SELECT pg_advisory_xact_lock($1::bigint)", [
|
|
47262
|
+
LATTICE_MIGRATION_LOCK_ID.toString()
|
|
47263
|
+
]);
|
|
47264
|
+
await tx.run(sql);
|
|
47265
|
+
});
|
|
47266
|
+
} else {
|
|
47267
|
+
await runAsyncOrSync(adapter, sql);
|
|
47268
|
+
}
|
|
47269
|
+
}
|
|
46976
47270
|
function isPg(db) {
|
|
46977
47271
|
return db.getDialect() === "postgres";
|
|
46978
47272
|
}
|
|
@@ -46983,6 +47277,31 @@ function pkSqlExpr(pkCols, prefix) {
|
|
|
46983
47277
|
return pkCols.map((c6) => `CAST(${prefix}"${c6}" AS TEXT)`).join(` || chr(9) || `);
|
|
46984
47278
|
}
|
|
46985
47279
|
var MEMBER_GROUP = "lattice_members";
|
|
47280
|
+
function pinDefinerSearchPath(sql, schema) {
|
|
47281
|
+
const safe = schema.replace(/"/g, '""');
|
|
47282
|
+
return sql.replace(
|
|
47283
|
+
/SECURITY DEFINER AS/g,
|
|
47284
|
+
`SECURITY DEFINER SET search_path = "${safe}", pg_temp AS`
|
|
47285
|
+
);
|
|
47286
|
+
}
|
|
47287
|
+
async function cloudSchema(db) {
|
|
47288
|
+
const row = await getAsyncOrSync(db.adapter, `SELECT current_schema() AS schema`);
|
|
47289
|
+
const s2 = row?.schema;
|
|
47290
|
+
if (typeof s2 !== "string" || s2.length === 0) {
|
|
47291
|
+
throw new Error("cloud RLS: could not resolve current_schema() for search_path pinning");
|
|
47292
|
+
}
|
|
47293
|
+
return s2;
|
|
47294
|
+
}
|
|
47295
|
+
function revokeSchemaCreateSql(schema) {
|
|
47296
|
+
const lit = `'${schema.replace(/'/g, "''")}'`;
|
|
47297
|
+
return `
|
|
47298
|
+
DO $LATTICE_REVOKE$ BEGIN
|
|
47299
|
+
EXECUTE format('REVOKE CREATE ON SCHEMA %I FROM PUBLIC', ${lit});
|
|
47300
|
+
EXCEPTION WHEN OTHERS THEN
|
|
47301
|
+
NULL; -- not the schema owner, or already revoked
|
|
47302
|
+
END $LATTICE_REVOKE$;
|
|
47303
|
+
`;
|
|
47304
|
+
}
|
|
46986
47305
|
var CLOUD_RLS_BOOTSTRAP_SQL = `
|
|
46987
47306
|
-- Member group (NOLOGIN). Members inherit schema/connect/table privileges from it;
|
|
46988
47307
|
-- RLS filters per the individual member's login role, so the group never widens
|
|
@@ -47042,6 +47361,82 @@ CREATE TABLE IF NOT EXISTS "__lattice_cell_grants" (
|
|
|
47042
47361
|
PRIMARY KEY ("table_name", "pk", "column_name", "grantee_role")
|
|
47043
47362
|
);
|
|
47044
47363
|
|
|
47364
|
+
-- Per-table policy: the owner-controlled defaults that govern a whole table.
|
|
47365
|
+
-- default_row_visibility is the visibility NEW rows are stamped with (the insert
|
|
47366
|
+
-- trigger reads it); never_share is a hard exclusion \u2014 the share/grant functions
|
|
47367
|
+
-- refuse to elevate such a table and the trigger forces its rows private. Owner-
|
|
47368
|
+
-- managed; members have no grant (it never appears in their data API).
|
|
47369
|
+
CREATE TABLE IF NOT EXISTS "__lattice_table_policy" (
|
|
47370
|
+
"table_name" text PRIMARY KEY,
|
|
47371
|
+
"default_row_visibility" text NOT NULL DEFAULT 'private'
|
|
47372
|
+
CHECK ("default_row_visibility" IN ('private','everyone')),
|
|
47373
|
+
"never_share" boolean NOT NULL DEFAULT false,
|
|
47374
|
+
"updated_by" text NOT NULL DEFAULT session_user,
|
|
47375
|
+
"updated_at" timestamptz NOT NULL DEFAULT now()
|
|
47376
|
+
);
|
|
47377
|
+
|
|
47378
|
+
-- Per-column audience policy: the CANONICAL store of which column carries which
|
|
47379
|
+
-- audience spec (role: / subject: / source: / owner / everyone). Previously the
|
|
47380
|
+
-- spec lived only in the owner's on-disk YAML and was compiled into the mask view
|
|
47381
|
+
-- once at init; storing it here makes it cloud-canonical and member-consistent.
|
|
47382
|
+
-- The generated <table>_v mask view is regenerated from THIS table on change.
|
|
47383
|
+
-- Owner-managed; members have no grant.
|
|
47384
|
+
CREATE TABLE IF NOT EXISTS "__lattice_column_policy" (
|
|
47385
|
+
"table_name" text NOT NULL,
|
|
47386
|
+
"column_name" text NOT NULL,
|
|
47387
|
+
"audience" text NOT NULL,
|
|
47388
|
+
"updated_by" text NOT NULL DEFAULT session_user,
|
|
47389
|
+
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
|
47390
|
+
PRIMARY KEY ("table_name", "column_name")
|
|
47391
|
+
);
|
|
47392
|
+
|
|
47393
|
+
-- Owner-only audit of issued member invites: which scoped role was minted for
|
|
47394
|
+
-- which email (HASHED \u2014 the plaintext email is never stored), when it expires,
|
|
47395
|
+
-- and whether it was redeemed/revoked. No plaintext password is ever stored
|
|
47396
|
+
-- (the credential lives only inside the email-bound token the owner delivers).
|
|
47397
|
+
-- Owner-managed; members have no grant. Named distinctly from any legacy
|
|
47398
|
+
-- team-model invitations table so a pre-existing cloud never collides.
|
|
47399
|
+
CREATE TABLE IF NOT EXISTS "__lattice_member_invites" (
|
|
47400
|
+
"id" text PRIMARY KEY,
|
|
47401
|
+
"role" text NOT NULL,
|
|
47402
|
+
"email_hash" text NOT NULL,
|
|
47403
|
+
"email" text,
|
|
47404
|
+
"created_by" text NOT NULL DEFAULT session_user,
|
|
47405
|
+
"created_at" timestamptz NOT NULL DEFAULT now(),
|
|
47406
|
+
"expires_at" timestamptz NOT NULL,
|
|
47407
|
+
"redeemed_at" timestamptz,
|
|
47408
|
+
"revoked_at" timestamptz
|
|
47409
|
+
);
|
|
47410
|
+
-- Plaintext invitee email (owner-only table; members have no grant) so the
|
|
47411
|
+
-- owner's Members list can show who each member is. Added via ALTER so clouds
|
|
47412
|
+
-- created before this column converge to it on the owner's next open (the
|
|
47413
|
+
-- bootstrap is now run directly + idempotently, not version-gated).
|
|
47414
|
+
ALTER TABLE "__lattice_member_invites" ADD COLUMN IF NOT EXISTS "email" text;
|
|
47415
|
+
|
|
47416
|
+
-- #3.1 \u2014 one-time-use + revocation enforcement. After a member authenticates to
|
|
47417
|
+
-- the cloud with their minted credential, the join path calls this to CLAIM the
|
|
47418
|
+
-- invite. The single atomic UPDATE stamps redeemed_at and returns true ONLY when
|
|
47419
|
+
-- an invite for the CALLING role (session_user) is still pending: not already
|
|
47420
|
+
-- redeemed (one-time-use), not revoked, and not expired. A replayed redeem of a
|
|
47421
|
+
-- leaked token, a revoked invite, or an expired one returns false, so the caller
|
|
47422
|
+
-- rejects the join. Members have no direct grant on the owner-only
|
|
47423
|
+
-- __lattice_member_invites table \u2014 this SECURITY DEFINER function is the only
|
|
47424
|
+
-- path, and it can claim ONLY the caller's own invite (keyed on session_user,
|
|
47425
|
+
-- never a caller-supplied parameter, so one member can't burn another's invite).
|
|
47426
|
+
CREATE OR REPLACE FUNCTION lattice_claim_invite()
|
|
47427
|
+
RETURNS boolean LANGUAGE plpgsql SECURITY DEFINER AS $fn$
|
|
47428
|
+
DECLARE v_ok boolean;
|
|
47429
|
+
BEGIN
|
|
47430
|
+
UPDATE "__lattice_member_invites"
|
|
47431
|
+
SET "redeemed_at" = now()
|
|
47432
|
+
WHERE "role" = session_user
|
|
47433
|
+
AND "redeemed_at" IS NULL
|
|
47434
|
+
AND "revoked_at" IS NULL
|
|
47435
|
+
AND "expires_at" > now()
|
|
47436
|
+
RETURNING true INTO v_ok;
|
|
47437
|
+
RETURN COALESCE(v_ok, false);
|
|
47438
|
+
END $fn$;
|
|
47439
|
+
|
|
47045
47440
|
-- Visibility check. SECURITY DEFINER so it reads bookkeeping the member can't;
|
|
47046
47441
|
-- keyed on session_user (the member's login role). A row with no ownership record
|
|
47047
47442
|
-- is visible to nobody.
|
|
@@ -47067,6 +47462,10 @@ BEGIN
|
|
|
47067
47462
|
IF p_visibility NOT IN ('private','everyone','custom') THEN
|
|
47068
47463
|
RAISE EXCEPTION 'lattice: invalid visibility %', p_visibility;
|
|
47069
47464
|
END IF;
|
|
47465
|
+
IF p_visibility <> 'private'
|
|
47466
|
+
AND COALESCE((SELECT "never_share" FROM "__lattice_table_policy" WHERE "table_name" = p_table), false) THEN
|
|
47467
|
+
RAISE EXCEPTION 'lattice: "%" is a private-only table and cannot be shared', p_table;
|
|
47468
|
+
END IF;
|
|
47070
47469
|
SELECT o."owner_role" INTO v_owner FROM "__lattice_owners" o
|
|
47071
47470
|
WHERE o."table_name" = p_table AND o."pk" = p_pk;
|
|
47072
47471
|
IF v_owner IS NULL THEN RAISE EXCEPTION 'lattice: no ownership record for %/%', p_table, p_pk; END IF;
|
|
@@ -47080,6 +47479,9 @@ CREATE OR REPLACE FUNCTION lattice_grant_row(p_table text, p_pk text, p_grantee
|
|
|
47080
47479
|
RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $fn$
|
|
47081
47480
|
DECLARE v_owner text;
|
|
47082
47481
|
BEGIN
|
|
47482
|
+
IF COALESCE((SELECT "never_share" FROM "__lattice_table_policy" WHERE "table_name" = p_table), false) THEN
|
|
47483
|
+
RAISE EXCEPTION 'lattice: "%" is a private-only table and cannot be shared', p_table;
|
|
47484
|
+
END IF;
|
|
47083
47485
|
SELECT o."owner_role" INTO v_owner FROM "__lattice_owners" o
|
|
47084
47486
|
WHERE o."table_name" = p_table AND o."pk" = p_pk;
|
|
47085
47487
|
IF v_owner IS NULL THEN RAISE EXCEPTION 'lattice: no ownership record for %/%', p_table, p_pk; END IF;
|
|
@@ -47169,6 +47571,9 @@ CREATE OR REPLACE FUNCTION lattice_grant_cell(p_table text, p_pk text, p_column
|
|
|
47169
47571
|
RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $fn$
|
|
47170
47572
|
DECLARE v_owner text;
|
|
47171
47573
|
BEGIN
|
|
47574
|
+
IF COALESCE((SELECT "never_share" FROM "__lattice_table_policy" WHERE "table_name" = p_table), false) THEN
|
|
47575
|
+
RAISE EXCEPTION 'lattice: "%" is a private-only table and cannot be shared', p_table;
|
|
47576
|
+
END IF;
|
|
47172
47577
|
SELECT o."owner_role" INTO v_owner FROM "__lattice_owners" o
|
|
47173
47578
|
WHERE o."table_name" = p_table AND o."pk" = p_pk;
|
|
47174
47579
|
IF v_owner IS NULL OR v_owner <> session_user THEN
|
|
@@ -47201,6 +47606,87 @@ RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER AS $fn$
|
|
|
47201
47606
|
SELECT lattice_row_visible('files', p_source_ref)
|
|
47202
47607
|
$fn$;
|
|
47203
47608
|
|
|
47609
|
+
-- Is the connected member the OWNER of this row? Used by the "owner" column
|
|
47610
|
+
-- audience (a secret column reveals only to the row owner). SECURITY DEFINER +
|
|
47611
|
+
-- session_user, like the other predicates.
|
|
47612
|
+
CREATE OR REPLACE FUNCTION lattice_is_owner(p_table text, p_pk text)
|
|
47613
|
+
RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER AS $fn$
|
|
47614
|
+
SELECT EXISTS (
|
|
47615
|
+
SELECT 1 FROM "__lattice_owners" o
|
|
47616
|
+
WHERE o."table_name" = p_table AND o."pk" = p_pk AND o."owner_role" = session_user
|
|
47617
|
+
)
|
|
47618
|
+
$fn$;
|
|
47619
|
+
|
|
47620
|
+
-- Owner-only: set a table's default row visibility for NEW rows. Raises unless the
|
|
47621
|
+
-- caller can create roles (a cloud owner / DBA), like lattice_assign_role. Rejects
|
|
47622
|
+
-- 'everyone' on a never-share table.
|
|
47623
|
+
CREATE OR REPLACE FUNCTION lattice_set_table_default_visibility(p_table text, p_visibility text)
|
|
47624
|
+
RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $fn$
|
|
47625
|
+
BEGIN
|
|
47626
|
+
IF NOT (SELECT rolcreaterole FROM pg_roles WHERE rolname = session_user) THEN
|
|
47627
|
+
RAISE EXCEPTION 'lattice: only a cloud owner may set a table''s default visibility';
|
|
47628
|
+
END IF;
|
|
47629
|
+
IF p_visibility NOT IN ('private','everyone') THEN
|
|
47630
|
+
RAISE EXCEPTION 'lattice: invalid default visibility %', p_visibility;
|
|
47631
|
+
END IF;
|
|
47632
|
+
IF p_visibility = 'everyone'
|
|
47633
|
+
AND COALESCE((SELECT "never_share" FROM "__lattice_table_policy" WHERE "table_name" = p_table), false) THEN
|
|
47634
|
+
RAISE EXCEPTION 'lattice: "%" is a private-only table; its rows cannot default to everyone', p_table;
|
|
47635
|
+
END IF;
|
|
47636
|
+
INSERT INTO "__lattice_table_policy" ("table_name","default_row_visibility","updated_by","updated_at")
|
|
47637
|
+
VALUES (p_table, p_visibility, session_user, now())
|
|
47638
|
+
ON CONFLICT ("table_name") DO UPDATE
|
|
47639
|
+
SET "default_row_visibility" = EXCLUDED."default_row_visibility",
|
|
47640
|
+
"updated_by" = session_user, "updated_at" = now();
|
|
47641
|
+
END $fn$;
|
|
47642
|
+
|
|
47643
|
+
-- Owner-only: mark a table never-shareable (Secrets/Messages-class). When true the
|
|
47644
|
+
-- share/grant functions raise and the insert trigger forces new rows private; the
|
|
47645
|
+
-- default visibility is also forced private. Turning it ON also RETROACTIVELY
|
|
47646
|
+
-- privatizes the table: any row currently shared ('everyone'/'custom') is reset to
|
|
47647
|
+
-- 'private' and every existing row/cell grant on the table is dropped \u2014 otherwise
|
|
47648
|
+
-- flagging a table never-share would leave already-leaked rows visible, defeating
|
|
47649
|
+
-- the point. Idempotent: re-running with already-private rows updates nothing.
|
|
47650
|
+
CREATE OR REPLACE FUNCTION lattice_set_table_never_share(p_table text, p_on boolean)
|
|
47651
|
+
RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $fn$
|
|
47652
|
+
BEGIN
|
|
47653
|
+
IF NOT (SELECT rolcreaterole FROM pg_roles WHERE rolname = session_user) THEN
|
|
47654
|
+
RAISE EXCEPTION 'lattice: only a cloud owner may change a table''s never-share flag';
|
|
47655
|
+
END IF;
|
|
47656
|
+
INSERT INTO "__lattice_table_policy" ("table_name","never_share","default_row_visibility","updated_by","updated_at")
|
|
47657
|
+
VALUES (p_table, p_on, CASE WHEN p_on THEN 'private' ELSE 'private' END, session_user, now())
|
|
47658
|
+
ON CONFLICT ("table_name") DO UPDATE
|
|
47659
|
+
SET "never_share" = EXCLUDED."never_share",
|
|
47660
|
+
"default_row_visibility" = CASE WHEN EXCLUDED."never_share"
|
|
47661
|
+
THEN 'private' ELSE "__lattice_table_policy"."default_row_visibility" END,
|
|
47662
|
+
"updated_by" = session_user, "updated_at" = now();
|
|
47663
|
+
IF p_on THEN
|
|
47664
|
+
UPDATE "__lattice_owners" SET "visibility" = 'private', "updated_at" = now()
|
|
47665
|
+
WHERE "table_name" = p_table AND "visibility" <> 'private';
|
|
47666
|
+
DELETE FROM "__lattice_row_grants" WHERE "table_name" = p_table;
|
|
47667
|
+
DELETE FROM "__lattice_cell_grants" WHERE "table_name" = p_table;
|
|
47668
|
+
END IF;
|
|
47669
|
+
END $fn$;
|
|
47670
|
+
|
|
47671
|
+
-- Owner-only: set (or clear) a column's audience spec in the canonical DB store.
|
|
47672
|
+
-- An empty/null spec removes the policy row (column becomes unmasked). The GUI/lib
|
|
47673
|
+
-- regenerates the table's mask view from this store after calling this.
|
|
47674
|
+
CREATE OR REPLACE FUNCTION lattice_set_column_audience(p_table text, p_column text, p_audience text)
|
|
47675
|
+
RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $fn$
|
|
47676
|
+
BEGIN
|
|
47677
|
+
IF NOT (SELECT rolcreaterole FROM pg_roles WHERE rolname = session_user) THEN
|
|
47678
|
+
RAISE EXCEPTION 'lattice: only a cloud owner may set a column audience';
|
|
47679
|
+
END IF;
|
|
47680
|
+
IF p_audience IS NULL OR btrim(p_audience) = '' THEN
|
|
47681
|
+
DELETE FROM "__lattice_column_policy" WHERE "table_name" = p_table AND "column_name" = p_column;
|
|
47682
|
+
ELSE
|
|
47683
|
+
INSERT INTO "__lattice_column_policy" ("table_name","column_name","audience","updated_by","updated_at")
|
|
47684
|
+
VALUES (p_table, p_column, p_audience, session_user, now())
|
|
47685
|
+
ON CONFLICT ("table_name","column_name") DO UPDATE
|
|
47686
|
+
SET "audience" = EXCLUDED."audience", "updated_by" = session_user, "updated_at" = now();
|
|
47687
|
+
END IF;
|
|
47688
|
+
END $fn$;
|
|
47689
|
+
|
|
47204
47690
|
-- Append-only change feed. The per-table ownership trigger records one row per
|
|
47205
47691
|
-- INSERT/UPDATE/DELETE; the AFTER INSERT trigger here fires pg_notify so a
|
|
47206
47692
|
-- connected member's realtime broker refreshes. Members get no direct access \u2014
|
|
@@ -47232,6 +47718,62 @@ END $fn$;
|
|
|
47232
47718
|
DROP TRIGGER IF EXISTS "lattice_notify_change_trg" ON "__lattice_changes";
|
|
47233
47719
|
CREATE TRIGGER "lattice_notify_change_trg" AFTER INSERT ON "__lattice_changes"
|
|
47234
47720
|
FOR EACH ROW EXECUTE FUNCTION lattice_notify_change();
|
|
47721
|
+
|
|
47722
|
+
-- #4.4 \u2014 seq-based catch-up after a realtime gap. NOTIFY is fire-and-forget, so a
|
|
47723
|
+
-- broker that drops its LISTEN (network blip, laptop sleep) misses every change
|
|
47724
|
+
-- during the gap. The broker tracks the highest seq it delivered and, on
|
|
47725
|
+
-- reconnect, replays what it missed via this function. Members have NO direct
|
|
47726
|
+
-- grant on __lattice_changes (reading it raw would leak every change on the
|
|
47727
|
+
-- cloud), so this SECURITY DEFINER function is the only path and it returns ONLY
|
|
47728
|
+
-- the rows the CALLING role can see: keyed on session_user via lattice_row_visible
|
|
47729
|
+
-- (same gate as live fan-out, #4.3). Deletes are excluded \u2014 the ownership record
|
|
47730
|
+
-- is gone post-delete so visibility can't be verified, and replaying them would
|
|
47731
|
+
-- leak deleted-row pks (the client reconciles deletes on its reconnect refetch).
|
|
47732
|
+
-- Bounded (LIMIT clamped \u2264 1000) so a long gap can't stream the whole table (Rule:
|
|
47733
|
+
-- bounded reads on a hot path).
|
|
47734
|
+
CREATE OR REPLACE FUNCTION lattice_changes_since(p_seq bigint, p_limit int)
|
|
47735
|
+
RETURNS TABLE(seq bigint, table_name text, pk text, op text, owner_role text, created_at timestamptz)
|
|
47736
|
+
LANGUAGE sql STABLE SECURITY DEFINER AS $fn$
|
|
47737
|
+
SELECT c."seq", c."table_name", c."pk", c."op", c."owner_role", c."created_at"
|
|
47738
|
+
FROM "__lattice_changes" c
|
|
47739
|
+
WHERE c."seq" > p_seq
|
|
47740
|
+
AND c."op" = 'upsert'
|
|
47741
|
+
AND lattice_row_visible(c."table_name", c."pk")
|
|
47742
|
+
ORDER BY c."seq" ASC
|
|
47743
|
+
LIMIT GREATEST(0, LEAST(COALESCE(p_limit, 500), 1000));
|
|
47744
|
+
$fn$;
|
|
47745
|
+
|
|
47746
|
+
-- #2.1 \u2014 per-row access summary for the connecting role. The GUI attaches this as
|
|
47747
|
+
-- each row's _access so the sharing affordance renders, but __lattice_owners is
|
|
47748
|
+
-- owner-only bookkeeping (members have no grant), so a member reading it directly
|
|
47749
|
+
-- got "permission denied". This SECURITY DEFINER function returns visibility +
|
|
47750
|
+
-- whether the CALLER owns the row, ONLY for the rows the caller can actually see
|
|
47751
|
+
-- (lattice_row_visible, keyed on session_user) \u2014 so a member learns nothing about
|
|
47752
|
+
-- rows hidden from it. Member-callable; the owner gets the same view of its rows.
|
|
47753
|
+
CREATE OR REPLACE FUNCTION lattice_rows_access(p_table text, p_pks text[])
|
|
47754
|
+
RETURNS TABLE(pk text, visibility text, owned boolean)
|
|
47755
|
+
LANGUAGE sql STABLE SECURITY DEFINER AS $fn$
|
|
47756
|
+
SELECT o."pk", o."visibility", (o."owner_role" = session_user) AS owned
|
|
47757
|
+
FROM "__lattice_owners" o
|
|
47758
|
+
WHERE o."table_name" = p_table
|
|
47759
|
+
AND o."pk" = ANY(p_pks)
|
|
47760
|
+
AND lattice_row_visible(o."table_name", o."pk");
|
|
47761
|
+
$fn$;
|
|
47762
|
+
|
|
47763
|
+
-- #2.1 \u2014 grantees of a CALLER-OWNED custom-shared row (who you shared YOUR row
|
|
47764
|
+
-- with). Only the row owner sees this (the WHERE pins owner_role = session_user),
|
|
47765
|
+
-- so a member can't enumerate another owner's grants. __lattice_row_grants is
|
|
47766
|
+
-- member-ungranted, so this SECURITY DEFINER function is the member-safe path.
|
|
47767
|
+
CREATE OR REPLACE FUNCTION lattice_row_grantees(p_table text, p_pks text[])
|
|
47768
|
+
RETURNS TABLE(pk text, grantee_role text)
|
|
47769
|
+
LANGUAGE sql STABLE SECURITY DEFINER AS $fn$
|
|
47770
|
+
SELECT g."pk", g."grantee_role"
|
|
47771
|
+
FROM "__lattice_row_grants" g
|
|
47772
|
+
JOIN "__lattice_owners" o ON o."table_name" = g."table_name" AND o."pk" = g."pk"
|
|
47773
|
+
WHERE g."table_name" = p_table
|
|
47774
|
+
AND g."pk" = ANY(p_pks)
|
|
47775
|
+
AND o."owner_role" = session_user;
|
|
47776
|
+
$fn$;
|
|
47235
47777
|
`;
|
|
47236
47778
|
function tableRlsSql(table, pkCols) {
|
|
47237
47779
|
const q3 = `"${table.replace(/"/g, '""')}"`;
|
|
@@ -47245,7 +47787,20 @@ CREATE OR REPLACE FUNCTION "${trg}"() RETURNS trigger LANGUAGE plpgsql SECURITY
|
|
|
47245
47787
|
BEGIN
|
|
47246
47788
|
IF TG_OP = 'INSERT' THEN
|
|
47247
47789
|
INSERT INTO "__lattice_owners" ("table_name","pk","owner_role","visibility")
|
|
47248
|
-
VALUES (${lit}, ${pkNew}, session_user,
|
|
47790
|
+
VALUES (${lit}, ${pkNew}, session_user,
|
|
47791
|
+
CASE
|
|
47792
|
+
-- never-share always wins: such a table's rows are private, full stop.
|
|
47793
|
+
WHEN COALESCE((SELECT "never_share" FROM "__lattice_table_policy" WHERE "table_name" = ${lit}), false)
|
|
47794
|
+
THEN 'private'
|
|
47795
|
+
-- per-INSERT override: a caller forcing visibility for THIS write (e.g.
|
|
47796
|
+
-- chat "private mode") sets the transaction-local lattice.force_row_visibility
|
|
47797
|
+
-- GUC, so the row is stamped atomically at insert \u2014 never momentarily at
|
|
47798
|
+
-- the table default, and the change-feed NOTIFY (deferred to COMMIT) only
|
|
47799
|
+
-- fires once the row already carries this visibility.
|
|
47800
|
+
WHEN NULLIF(current_setting('lattice.force_row_visibility', true), '') IN ('private','everyone')
|
|
47801
|
+
THEN current_setting('lattice.force_row_visibility', true)
|
|
47802
|
+
ELSE COALESCE((SELECT "default_row_visibility" FROM "__lattice_table_policy" WHERE "table_name" = ${lit}), 'private')
|
|
47803
|
+
END)
|
|
47249
47804
|
ON CONFLICT ("table_name","pk") DO NOTHING;
|
|
47250
47805
|
INSERT INTO "__lattice_changes" ("table_name","pk","op","owner_role")
|
|
47251
47806
|
VALUES (${lit}, ${pkNew}, 'upsert', session_user);
|
|
@@ -47285,20 +47840,15 @@ CREATE TRIGGER "${trg}" AFTER INSERT OR UPDATE OR DELETE ON ${q3}
|
|
|
47285
47840
|
}
|
|
47286
47841
|
async function installCloudRls(db) {
|
|
47287
47842
|
if (!isPg(db)) return;
|
|
47288
|
-
const
|
|
47289
|
-
|
|
47290
|
-
|
|
47291
|
-
// The bootstrap is fully idempotent (CREATE OR REPLACE / IF NOT EXISTS).
|
|
47292
|
-
version: "internal:cloud-rls:bootstrap:v5",
|
|
47293
|
-
sql: CLOUD_RLS_BOOTSTRAP_SQL
|
|
47294
|
-
};
|
|
47295
|
-
await db.migrate([migration]);
|
|
47843
|
+
const schema = await cloudSchema(db);
|
|
47844
|
+
const sql = pinDefinerSearchPath(CLOUD_RLS_BOOTSTRAP_SQL, schema) + revokeSchemaCreateSql(schema);
|
|
47845
|
+
await runCloudBootstrapSql(db, sql);
|
|
47296
47846
|
}
|
|
47297
47847
|
async function enableChangelogRls(db) {
|
|
47298
47848
|
if (!isPg(db)) return;
|
|
47299
|
-
|
|
47300
|
-
|
|
47301
|
-
|
|
47849
|
+
await runCloudBootstrapSql(
|
|
47850
|
+
db,
|
|
47851
|
+
`
|
|
47302
47852
|
ALTER TABLE "__lattice_changelog" ENABLE ROW LEVEL SECURITY;
|
|
47303
47853
|
ALTER TABLE "__lattice_changelog" FORCE ROW LEVEL SECURITY;
|
|
47304
47854
|
GRANT SELECT, INSERT ON "__lattice_changelog" TO ${MEMBER_GROUP};
|
|
@@ -47308,24 +47858,25 @@ CREATE POLICY "lattice_changelog_sel" ON "__lattice_changelog" FOR SELECT USING
|
|
|
47308
47858
|
CASE
|
|
47309
47859
|
WHEN "change_kind" = 'derived' THEN
|
|
47310
47860
|
"source_ref" IS NOT NULL
|
|
47861
|
+
AND jsonb_array_length("source_ref"::jsonb) > 0
|
|
47311
47862
|
AND NOT EXISTS (
|
|
47312
47863
|
SELECT 1 FROM jsonb_array_elements_text("source_ref"::jsonb) AS src(sid)
|
|
47313
47864
|
WHERE NOT lattice_source_visible(src.sid)
|
|
47314
47865
|
)
|
|
47315
|
-
ELSE
|
|
47866
|
+
ELSE lattice_is_owner("table_name", "row_id")
|
|
47316
47867
|
END
|
|
47317
47868
|
);
|
|
47318
47869
|
DROP POLICY IF EXISTS "lattice_changelog_ins" ON "__lattice_changelog";
|
|
47319
47870
|
CREATE POLICY "lattice_changelog_ins" ON "__lattice_changelog" FOR INSERT WITH CHECK (true);
|
|
47320
47871
|
`
|
|
47321
|
-
|
|
47322
|
-
await db.migrate([migration]);
|
|
47872
|
+
);
|
|
47323
47873
|
}
|
|
47324
47874
|
async function enableRlsForTable(db, table, pkCols) {
|
|
47325
47875
|
if (!isPg(db)) return;
|
|
47876
|
+
const schema = await cloudSchema(db);
|
|
47326
47877
|
const migration = {
|
|
47327
|
-
version: `internal:cloud-rls:table:${table}:
|
|
47328
|
-
sql: tableRlsSql(table, pkCols)
|
|
47878
|
+
version: `internal:cloud-rls:table:${table}:v3`,
|
|
47879
|
+
sql: pinDefinerSearchPath(tableRlsSql(table, pkCols), schema)
|
|
47329
47880
|
};
|
|
47330
47881
|
await db.migrate([migration]);
|
|
47331
47882
|
}
|
|
@@ -47372,7 +47923,12 @@ async function provisionMemberRole(db, role, password) {
|
|
|
47372
47923
|
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${role}') THEN
|
|
47373
47924
|
CREATE ROLE "${role}" LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE PASSWORD '${password}';
|
|
47374
47925
|
ELSE
|
|
47375
|
-
|
|
47926
|
+
-- Re-invite of an EXISTING role: set ONLY what changed (login + password).
|
|
47927
|
+
-- Restating NOSUPERUSER/superuser-class attrs trips Supabase supautils
|
|
47928
|
+
-- ("only superuser may alter the SUPERUSER attribute", 42501) since the
|
|
47929
|
+
-- owner 'postgres' isn't a true superuser. The role was already created
|
|
47930
|
+
-- NOSUPERUSER NOCREATEDB NOCREATEROLE, so there is nothing to restate.
|
|
47931
|
+
ALTER ROLE "${role}" WITH LOGIN PASSWORD '${password}';
|
|
47376
47932
|
END IF;
|
|
47377
47933
|
END $LATTICE$`
|
|
47378
47934
|
);
|
|
@@ -47411,9 +47967,28 @@ async function revokeCell(db, table, pk, column, grantee) {
|
|
|
47411
47967
|
async function revokeMemberRole(db, role) {
|
|
47412
47968
|
assertPg(db);
|
|
47413
47969
|
if (!ROLE_RE.test(role)) throw new Error(`lattice: invalid member role name "${role}"`);
|
|
47414
|
-
|
|
47970
|
+
const exists = await getAsyncOrSync(
|
|
47971
|
+
db.adapter,
|
|
47972
|
+
`SELECT 1 AS x FROM pg_roles WHERE rolname = ?`,
|
|
47973
|
+
[role]
|
|
47974
|
+
);
|
|
47975
|
+
if (!exists) return;
|
|
47976
|
+
for (const stmt of [`REASSIGN OWNED BY "${role}" TO CURRENT_USER`, `DROP OWNED BY "${role}"`]) {
|
|
47977
|
+
try {
|
|
47978
|
+
await runAsyncOrSync(db.adapter, stmt);
|
|
47979
|
+
} catch (e6) {
|
|
47980
|
+
if (!isInsufficientPrivilege(e6)) throw e6;
|
|
47981
|
+
console.warn(
|
|
47982
|
+
`[cloud] "${stmt.split(" ").slice(0, 2).join(" ")} \u2026" skipped (insufficient privilege; a scoped member owns no objects): ${e6.message}`
|
|
47983
|
+
);
|
|
47984
|
+
}
|
|
47985
|
+
}
|
|
47415
47986
|
await runAsyncOrSync(db.adapter, `DROP ROLE IF EXISTS "${role}"`);
|
|
47416
47987
|
}
|
|
47988
|
+
function isInsufficientPrivilege(e6) {
|
|
47989
|
+
const err = e6 ?? {};
|
|
47990
|
+
return err.code === "42501" || /permission denied/i.test(err.message ?? "");
|
|
47991
|
+
}
|
|
47417
47992
|
|
|
47418
47993
|
// src/cloud/discover.ts
|
|
47419
47994
|
async function discoverCloudTables(db) {
|
|
@@ -47455,12 +48030,17 @@ function isRowAudience(audience) {
|
|
|
47455
48030
|
const a6 = (audience ?? "").trim();
|
|
47456
48031
|
return a6 === "" || a6 === "everyone" || a6 === "row-audience";
|
|
47457
48032
|
}
|
|
47458
|
-
function audiencePredicate(audience) {
|
|
48033
|
+
function audiencePredicate(audience, ctx) {
|
|
47459
48034
|
if (isRowAudience(audience)) return "true";
|
|
47460
48035
|
const clauses = audience.split("+").map((c6) => c6.trim()).filter(Boolean);
|
|
47461
48036
|
const parts = [];
|
|
47462
48037
|
for (const clause of clauses) {
|
|
47463
48038
|
if (clause === "everyone" || clause === "row-audience") return "true";
|
|
48039
|
+
if (clause === "owner") {
|
|
48040
|
+
if (!ctx) throw new Error('lattice: the "owner" audience needs a row context');
|
|
48041
|
+
parts.push(`lattice_is_owner(${ctx.tableLit}, ${ctx.pkExpr})`);
|
|
48042
|
+
continue;
|
|
48043
|
+
}
|
|
47464
48044
|
const idx = clause.indexOf(":");
|
|
47465
48045
|
const kind = idx === -1 ? clause : clause.slice(0, idx);
|
|
47466
48046
|
const arg = idx === -1 ? "" : clause.slice(idx + 1).trim();
|
|
@@ -47495,7 +48075,7 @@ function audienceViewSql(table, columns, pkCols, columnAudience) {
|
|
|
47495
48075
|
const selectCols = columns.map((col) => {
|
|
47496
48076
|
const aud = columnAudience[col] ?? "";
|
|
47497
48077
|
if (isRowAudience(aud)) return quoteIdent(col);
|
|
47498
|
-
const pred = audiencePredicate(aud);
|
|
48078
|
+
const pred = audiencePredicate(aud, { tableLit: lit, pkExpr });
|
|
47499
48079
|
if (pred === "true") return quoteIdent(col);
|
|
47500
48080
|
const colLit = `'${col.replace(/'/g, "''")}'`;
|
|
47501
48081
|
const full = `(${pred}) OR lattice_cell_visible(${lit}, ${pkExpr}, ${colLit})`;
|
|
@@ -47530,6 +48110,92 @@ async function enableAudienceView(db, table, columns, pkCols, columnAudience) {
|
|
|
47530
48110
|
};
|
|
47531
48111
|
await db.migrate([migration]);
|
|
47532
48112
|
}
|
|
48113
|
+
async function loadColumnPolicy(db, table) {
|
|
48114
|
+
if (db.getDialect() !== "postgres") return {};
|
|
48115
|
+
const rows = await allAsyncOrSync(
|
|
48116
|
+
db.adapter,
|
|
48117
|
+
`SELECT "column_name", "audience" FROM "__lattice_column_policy" WHERE "table_name" = ?`,
|
|
48118
|
+
[table]
|
|
48119
|
+
);
|
|
48120
|
+
const out = {};
|
|
48121
|
+
for (const r6 of rows) out[r6.column_name] = r6.audience;
|
|
48122
|
+
return out;
|
|
48123
|
+
}
|
|
48124
|
+
async function seedColumnPolicyFromYaml(db, table, yamlAudience) {
|
|
48125
|
+
if (db.getDialect() !== "postgres") return;
|
|
48126
|
+
const marker = `internal:cloud-column-seed:${table}:v1`;
|
|
48127
|
+
const already = await getAsyncOrSync(
|
|
48128
|
+
db.adapter,
|
|
48129
|
+
`SELECT 1 AS one FROM "__lattice_migrations" WHERE "version" = ?`,
|
|
48130
|
+
[marker]
|
|
48131
|
+
);
|
|
48132
|
+
if (already) return;
|
|
48133
|
+
for (const [col, aud] of Object.entries(yamlAudience)) {
|
|
48134
|
+
if (isRowAudience(aud)) continue;
|
|
48135
|
+
await runAsyncOrSync(
|
|
48136
|
+
db.adapter,
|
|
48137
|
+
`INSERT INTO "__lattice_column_policy" ("table_name","column_name","audience")
|
|
48138
|
+
VALUES (?, ?, ?) ON CONFLICT ("table_name","column_name") DO NOTHING`,
|
|
48139
|
+
[table, col, aud]
|
|
48140
|
+
);
|
|
48141
|
+
}
|
|
48142
|
+
await runAsyncOrSync(
|
|
48143
|
+
db.adapter,
|
|
48144
|
+
`INSERT INTO "__lattice_migrations" ("version","applied_at") VALUES (?, ?)
|
|
48145
|
+
ON CONFLICT ("version") DO NOTHING`,
|
|
48146
|
+
[marker, (/* @__PURE__ */ new Date()).toISOString()]
|
|
48147
|
+
);
|
|
48148
|
+
}
|
|
48149
|
+
async function regenerateAudienceViewFromDb(db, table, columns, pkCols) {
|
|
48150
|
+
if (db.getDialect() !== "postgres") return;
|
|
48151
|
+
if (pkCols.length === 0) return;
|
|
48152
|
+
const spec = await loadColumnPolicy(db, table);
|
|
48153
|
+
const view = quoteIdent(`${table}_v`);
|
|
48154
|
+
const base = quoteIdent(table);
|
|
48155
|
+
if (!tableNeedsAudienceView(spec)) {
|
|
48156
|
+
await runAsyncOrSync(
|
|
48157
|
+
db.adapter,
|
|
48158
|
+
`DROP VIEW IF EXISTS ${view};
|
|
48159
|
+
GRANT SELECT ON ${base} TO ${MEMBER_GROUP};`
|
|
48160
|
+
);
|
|
48161
|
+
return;
|
|
48162
|
+
}
|
|
48163
|
+
await runAsyncOrSync(db.adapter, audienceViewSql(table, columns, pkCols, spec));
|
|
48164
|
+
}
|
|
48165
|
+
async function setColumnAudience(db, table, column, audience, columns, pkCols) {
|
|
48166
|
+
if (db.getDialect() !== "postgres") return;
|
|
48167
|
+
await runAsyncOrSync(db.adapter, `SELECT lattice_set_column_audience(?, ?, ?)`, [
|
|
48168
|
+
table,
|
|
48169
|
+
column,
|
|
48170
|
+
audience
|
|
48171
|
+
]);
|
|
48172
|
+
await regenerateAudienceViewFromDb(db, table, columns, pkCols);
|
|
48173
|
+
}
|
|
48174
|
+
|
|
48175
|
+
// src/cloud/table-policy.ts
|
|
48176
|
+
async function getTablePolicy(db, table) {
|
|
48177
|
+
if (db.getDialect() !== "postgres") return { defaultRowVisibility: "private", neverShare: false };
|
|
48178
|
+
const row = await getAsyncOrSync(
|
|
48179
|
+
db.adapter,
|
|
48180
|
+
`SELECT "default_row_visibility", "never_share" FROM "__lattice_table_policy" WHERE "table_name" = ?`,
|
|
48181
|
+
[table]
|
|
48182
|
+
);
|
|
48183
|
+
return {
|
|
48184
|
+
defaultRowVisibility: row?.default_row_visibility === "everyone" ? "everyone" : "private",
|
|
48185
|
+
neverShare: row?.never_share === true
|
|
48186
|
+
};
|
|
48187
|
+
}
|
|
48188
|
+
async function setTableDefaultVisibility(db, table, visibility) {
|
|
48189
|
+
if (db.getDialect() !== "postgres") return;
|
|
48190
|
+
await runAsyncOrSync(db.adapter, `SELECT lattice_set_table_default_visibility(?, ?)`, [
|
|
48191
|
+
table,
|
|
48192
|
+
visibility
|
|
48193
|
+
]);
|
|
48194
|
+
}
|
|
48195
|
+
async function setTableNeverShare(db, table, on) {
|
|
48196
|
+
if (db.getDialect() !== "postgres") return;
|
|
48197
|
+
await runAsyncOrSync(db.adapter, `SELECT lattice_set_table_never_share(?, ?)`, [table, on]);
|
|
48198
|
+
}
|
|
47533
48199
|
|
|
47534
48200
|
// src/cloud/fold-cache.ts
|
|
47535
48201
|
function viewerSignature(viewer) {
|
|
@@ -47566,6 +48232,7 @@ var FoldCache = class {
|
|
|
47566
48232
|
};
|
|
47567
48233
|
|
|
47568
48234
|
// src/cloud/settings.ts
|
|
48235
|
+
import { createHash as createHash8, randomBytes as randomBytes6 } from "crypto";
|
|
47569
48236
|
var CLOUD_SETTING_SYSTEM_PROMPT = "chat_system_prompt";
|
|
47570
48237
|
var CLOUD_SETTINGS_BOOTSTRAP_SQL = `
|
|
47571
48238
|
-- Owner-controlled, cloud-wide key/value settings. No grant to the member group,
|
|
@@ -47604,11 +48271,8 @@ END $fn$;
|
|
|
47604
48271
|
`;
|
|
47605
48272
|
async function installCloudSettings(db) {
|
|
47606
48273
|
if (db.getDialect() !== "postgres") return;
|
|
47607
|
-
const
|
|
47608
|
-
|
|
47609
|
-
sql: CLOUD_SETTINGS_BOOTSTRAP_SQL
|
|
47610
|
-
};
|
|
47611
|
-
await db.migrate([migration]);
|
|
48274
|
+
const schema = await cloudSchema(db);
|
|
48275
|
+
await runCloudBootstrapSql(db, pinDefinerSearchPath(CLOUD_SETTINGS_BOOTSTRAP_SQL, schema));
|
|
47612
48276
|
}
|
|
47613
48277
|
async function getCloudSetting(db, key) {
|
|
47614
48278
|
if (db.getDialect() !== "postgres") return null;
|
|
@@ -47627,23 +48291,39 @@ async function setCloudSetting(db, key, value) {
|
|
|
47627
48291
|
}
|
|
47628
48292
|
|
|
47629
48293
|
// src/cloud/setup.ts
|
|
48294
|
+
async function secureNewCloudTable(db, table, pk) {
|
|
48295
|
+
if (db.getDialect() !== "postgres") return;
|
|
48296
|
+
if (table.startsWith("__lattice_") || table.startsWith("_lattice_")) return;
|
|
48297
|
+
if (pk.length === 0) return;
|
|
48298
|
+
await backfillOwnership(db, table, pk);
|
|
48299
|
+
await enableRlsForTable(db, table, pk);
|
|
48300
|
+
const cols = db.getRegisteredColumns(table);
|
|
48301
|
+
if (cols) {
|
|
48302
|
+
await seedColumnPolicyFromYaml(db, table, db.getColumnAudience(table));
|
|
48303
|
+
await regenerateAudienceViewFromDb(db, table, Object.keys(cols), pk);
|
|
48304
|
+
}
|
|
48305
|
+
}
|
|
47630
48306
|
async function secureCloud(db) {
|
|
47631
48307
|
if (db.getDialect() !== "postgres") return;
|
|
47632
48308
|
await installCloudRls(db);
|
|
47633
48309
|
await installCloudSettings(db);
|
|
47634
48310
|
await db.ensureObservationSubstrate();
|
|
47635
48311
|
await enableChangelogRls(db);
|
|
47636
|
-
|
|
47637
|
-
|
|
47638
|
-
|
|
47639
|
-
|
|
47640
|
-
|
|
47641
|
-
await
|
|
47642
|
-
const cols = db.getRegisteredColumns(table);
|
|
47643
|
-
if (cols) {
|
|
47644
|
-
await enableAudienceView(db, table, Object.keys(cols), pk, db.getColumnAudience(table));
|
|
47645
|
-
}
|
|
48312
|
+
const registered = db.getRegisteredTableNames();
|
|
48313
|
+
for (const table of registered) {
|
|
48314
|
+
await secureNewCloudTable(db, table, db.getPrimaryKey(table));
|
|
48315
|
+
}
|
|
48316
|
+
if (registered.includes("secrets")) {
|
|
48317
|
+
await runAsyncOrSync(db.adapter, `SELECT lattice_set_table_never_share('secrets', true)`);
|
|
47646
48318
|
}
|
|
48319
|
+
await runAsyncOrSync(
|
|
48320
|
+
db.adapter,
|
|
48321
|
+
`DO $LATTICE$ BEGIN
|
|
48322
|
+
IF to_regclass('__lattice_user_identity') IS NOT NULL THEN
|
|
48323
|
+
EXECUTE 'GRANT SELECT, INSERT, UPDATE ON "__lattice_user_identity" TO ${MEMBER_GROUP}';
|
|
48324
|
+
END IF;
|
|
48325
|
+
END $LATTICE$`
|
|
48326
|
+
);
|
|
47647
48327
|
}
|
|
47648
48328
|
|
|
47649
48329
|
// src/ai/llm-client.ts
|
|
@@ -48237,6 +48917,7 @@ export {
|
|
|
48237
48917
|
NATIVE_ENTITY_NAMES,
|
|
48238
48918
|
NATIVE_REGISTRY_TABLE,
|
|
48239
48919
|
PostgresAdapter,
|
|
48920
|
+
ProgressThrottle,
|
|
48240
48921
|
READ_ONLY_HEADER,
|
|
48241
48922
|
ROOT_DIRNAME,
|
|
48242
48923
|
ReferenceUnavailableError,
|
|
@@ -48300,6 +48981,7 @@ export {
|
|
|
48300
48981
|
getCloudSetting,
|
|
48301
48982
|
getDbCredential,
|
|
48302
48983
|
getOrCreateMasterKey,
|
|
48984
|
+
getTablePolicy,
|
|
48303
48985
|
getWorkspace,
|
|
48304
48986
|
grantCell,
|
|
48305
48987
|
hasFtsIndex,
|
|
@@ -48317,6 +48999,7 @@ export {
|
|
|
48317
48999
|
listNativeBindings,
|
|
48318
49000
|
listTokens,
|
|
48319
49001
|
listWorkspaces,
|
|
49002
|
+
loadColumnPolicy,
|
|
48320
49003
|
manifestPath,
|
|
48321
49004
|
markdownTable,
|
|
48322
49005
|
memberRoleName,
|
|
@@ -48344,6 +49027,7 @@ export {
|
|
|
48344
49027
|
readToken,
|
|
48345
49028
|
referenceLocalFile,
|
|
48346
49029
|
referenceUrl,
|
|
49030
|
+
regenerateAudienceViewFromDb,
|
|
48347
49031
|
registerNativeEntities,
|
|
48348
49032
|
registryPath,
|
|
48349
49033
|
resolveActiveS3Config,
|
|
@@ -48358,9 +49042,13 @@ export {
|
|
|
48358
49042
|
saveDbCredentialForTeam,
|
|
48359
49043
|
sealUnderSource,
|
|
48360
49044
|
secureCloud,
|
|
49045
|
+
seedColumnPolicyFromYaml,
|
|
48361
49046
|
setActiveWorkspace,
|
|
48362
49047
|
setCloudSetting,
|
|
49048
|
+
setColumnAudience,
|
|
48363
49049
|
setRowVisibility,
|
|
49050
|
+
setTableDefaultVisibility,
|
|
49051
|
+
setTableNeverShare,
|
|
48364
49052
|
shredSource,
|
|
48365
49053
|
slugify,
|
|
48366
49054
|
summarizeText,
|