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.cjs
CHANGED
|
@@ -39405,6 +39405,7 @@ __export(index_exports, {
|
|
|
39405
39405
|
NATIVE_ENTITY_NAMES: () => NATIVE_ENTITY_NAMES,
|
|
39406
39406
|
NATIVE_REGISTRY_TABLE: () => NATIVE_REGISTRY_TABLE,
|
|
39407
39407
|
PostgresAdapter: () => PostgresAdapter,
|
|
39408
|
+
ProgressThrottle: () => ProgressThrottle,
|
|
39408
39409
|
READ_ONLY_HEADER: () => READ_ONLY_HEADER,
|
|
39409
39410
|
ROOT_DIRNAME: () => ROOT_DIRNAME,
|
|
39410
39411
|
ReferenceUnavailableError: () => ReferenceUnavailableError,
|
|
@@ -39468,6 +39469,7 @@ __export(index_exports, {
|
|
|
39468
39469
|
getCloudSetting: () => getCloudSetting,
|
|
39469
39470
|
getDbCredential: () => getDbCredential,
|
|
39470
39471
|
getOrCreateMasterKey: () => getOrCreateMasterKey,
|
|
39472
|
+
getTablePolicy: () => getTablePolicy,
|
|
39471
39473
|
getWorkspace: () => getWorkspace,
|
|
39472
39474
|
grantCell: () => grantCell,
|
|
39473
39475
|
hasFtsIndex: () => hasFtsIndex,
|
|
@@ -39485,6 +39487,7 @@ __export(index_exports, {
|
|
|
39485
39487
|
listNativeBindings: () => listNativeBindings,
|
|
39486
39488
|
listTokens: () => listTokens,
|
|
39487
39489
|
listWorkspaces: () => listWorkspaces,
|
|
39490
|
+
loadColumnPolicy: () => loadColumnPolicy,
|
|
39488
39491
|
manifestPath: () => manifestPath,
|
|
39489
39492
|
markdownTable: () => markdownTable,
|
|
39490
39493
|
memberRoleName: () => memberRoleName,
|
|
@@ -39512,6 +39515,7 @@ __export(index_exports, {
|
|
|
39512
39515
|
readToken: () => readToken,
|
|
39513
39516
|
referenceLocalFile: () => referenceLocalFile,
|
|
39514
39517
|
referenceUrl: () => referenceUrl,
|
|
39518
|
+
regenerateAudienceViewFromDb: () => regenerateAudienceViewFromDb,
|
|
39515
39519
|
registerNativeEntities: () => registerNativeEntities,
|
|
39516
39520
|
registryPath: () => registryPath,
|
|
39517
39521
|
resolveActiveS3Config: () => resolveActiveS3Config,
|
|
@@ -39526,9 +39530,13 @@ __export(index_exports, {
|
|
|
39526
39530
|
saveDbCredentialForTeam: () => saveDbCredentialForTeam,
|
|
39527
39531
|
sealUnderSource: () => sealUnderSource,
|
|
39528
39532
|
secureCloud: () => secureCloud,
|
|
39533
|
+
seedColumnPolicyFromYaml: () => seedColumnPolicyFromYaml,
|
|
39529
39534
|
setActiveWorkspace: () => setActiveWorkspace,
|
|
39530
39535
|
setCloudSetting: () => setCloudSetting,
|
|
39536
|
+
setColumnAudience: () => setColumnAudience,
|
|
39531
39537
|
setRowVisibility: () => setRowVisibility,
|
|
39538
|
+
setTableDefaultVisibility: () => setTableDefaultVisibility,
|
|
39539
|
+
setTableNeverShare: () => setTableNeverShare,
|
|
39532
39540
|
shredSource: () => shredSource,
|
|
39533
39541
|
slugify: () => slugify,
|
|
39534
39542
|
summarizeText: () => summarizeText,
|
|
@@ -41616,7 +41624,68 @@ function cleanupEntityContexts(outputDir, entityContexts, currentSlugsByTable, m
|
|
|
41616
41624
|
return result;
|
|
41617
41625
|
}
|
|
41618
41626
|
|
|
41627
|
+
// src/render/progress.ts
|
|
41628
|
+
var THROTTLE_WINDOW_MS = 200;
|
|
41629
|
+
var ProgressThrottle = class {
|
|
41630
|
+
cb;
|
|
41631
|
+
windowMs;
|
|
41632
|
+
/**
|
|
41633
|
+
* Last passthrough time, keyed per table (`event.table`, or `''` for the
|
|
41634
|
+
* table-less `done`/`error` lifecycle events). Per-table — not a single shared
|
|
41635
|
+
* clock — so when tables render CONCURRENTLY each one keeps its own ~5/sec
|
|
41636
|
+
* budget: a fast table can't consume the window and starve a slow table's
|
|
41637
|
+
* progress. `force` (table-start) resets only that table's budget.
|
|
41638
|
+
*/
|
|
41639
|
+
lastEmit = /* @__PURE__ */ new Map();
|
|
41640
|
+
constructor(cb, windowMs = THROTTLE_WINDOW_MS) {
|
|
41641
|
+
this.cb = cb;
|
|
41642
|
+
this.windowMs = windowMs;
|
|
41643
|
+
}
|
|
41644
|
+
/**
|
|
41645
|
+
* Emit a `table-progress` event, but only if the window since this table's
|
|
41646
|
+
* last passthrough has elapsed. Dropped events are simply not delivered — the
|
|
41647
|
+
* next one that survives carries the latest running count.
|
|
41648
|
+
*/
|
|
41649
|
+
tick(event) {
|
|
41650
|
+
if (!this.cb) return;
|
|
41651
|
+
const key = event.table ?? "";
|
|
41652
|
+
const now = Date.now();
|
|
41653
|
+
if (now - (this.lastEmit.get(key) ?? 0) < this.windowMs) return;
|
|
41654
|
+
this.lastEmit.set(key, now);
|
|
41655
|
+
this.cb(event);
|
|
41656
|
+
}
|
|
41657
|
+
/**
|
|
41658
|
+
* Emit a lifecycle event immediately and reset this table's throttle window.
|
|
41659
|
+
* Use for `table-start`, `table-done`, `done`, and `error` — none of which
|
|
41660
|
+
* should ever be dropped. Resetting on `table-start` gives each table a clean
|
|
41661
|
+
* budget.
|
|
41662
|
+
*/
|
|
41663
|
+
force(event) {
|
|
41664
|
+
if (!this.cb) return;
|
|
41665
|
+
this.lastEmit.set(event.table ?? "", Date.now());
|
|
41666
|
+
this.cb(event);
|
|
41667
|
+
}
|
|
41668
|
+
};
|
|
41669
|
+
|
|
41670
|
+
// src/concurrency.ts
|
|
41671
|
+
async function mapWithConcurrency(items, limit, fn) {
|
|
41672
|
+
const results = new Array(items.length);
|
|
41673
|
+
let next = 0;
|
|
41674
|
+
const workerCount = Math.max(1, Math.min(limit, items.length));
|
|
41675
|
+
const workers = Array.from({ length: workerCount }, async () => {
|
|
41676
|
+
for (; ; ) {
|
|
41677
|
+
const i6 = next++;
|
|
41678
|
+
if (i6 >= items.length) break;
|
|
41679
|
+
results[i6] = await fn(items[i6], i6);
|
|
41680
|
+
}
|
|
41681
|
+
});
|
|
41682
|
+
await Promise.all(workers);
|
|
41683
|
+
return results;
|
|
41684
|
+
}
|
|
41685
|
+
|
|
41619
41686
|
// src/render/engine.ts
|
|
41687
|
+
var YIELD_EVERY_ENTITIES = 200;
|
|
41688
|
+
var RENDER_TABLE_CONCURRENCY = 4;
|
|
41620
41689
|
var NOOP_RENDER = () => "";
|
|
41621
41690
|
var RenderEngine = class {
|
|
41622
41691
|
_schema;
|
|
@@ -41630,11 +41699,14 @@ var RenderEngine = class {
|
|
|
41630
41699
|
this._getTaskContext = getTaskContext ?? (() => "");
|
|
41631
41700
|
this._skipEmpty = options?.skipEmpty ?? false;
|
|
41632
41701
|
}
|
|
41633
|
-
async render(outputDir) {
|
|
41702
|
+
async render(outputDir, opts = {}) {
|
|
41634
41703
|
const start = Date.now();
|
|
41635
41704
|
const filesWritten = [];
|
|
41636
41705
|
const counters = { skipped: 0 };
|
|
41706
|
+
const signal = opts.signal;
|
|
41707
|
+
const throttle = new ProgressThrottle(opts.onProgress);
|
|
41637
41708
|
for (const [name, def] of this._schema.getTables()) {
|
|
41709
|
+
if (signal?.aborted) return this._abortedResult(filesWritten, counters, start);
|
|
41638
41710
|
if (this._skipEmpty && def.render === NOOP_RENDER) continue;
|
|
41639
41711
|
let rows = await this._schema.queryTable(this._adapter, name);
|
|
41640
41712
|
if (def.relevanceFilter) {
|
|
@@ -41676,8 +41748,18 @@ var RenderEngine = class {
|
|
|
41676
41748
|
} else {
|
|
41677
41749
|
counters.skipped++;
|
|
41678
41750
|
}
|
|
41751
|
+
throttle.force({
|
|
41752
|
+
kind: "table-done",
|
|
41753
|
+
table: name,
|
|
41754
|
+
entitiesRendered: rows.length,
|
|
41755
|
+
entitiesTotal: rows.length,
|
|
41756
|
+
tableIndex: 0,
|
|
41757
|
+
tableCount: 0,
|
|
41758
|
+
pct: 100
|
|
41759
|
+
});
|
|
41679
41760
|
}
|
|
41680
|
-
for (const [, def] of this._schema.getMultis()) {
|
|
41761
|
+
for (const [name, def] of this._schema.getMultis()) {
|
|
41762
|
+
if (signal?.aborted) return this._abortedResult(filesWritten, counters, start);
|
|
41681
41763
|
const keys = await def.keys();
|
|
41682
41764
|
const tables = {};
|
|
41683
41765
|
if (def.tables) {
|
|
@@ -41694,12 +41776,26 @@ var RenderEngine = class {
|
|
|
41694
41776
|
counters.skipped++;
|
|
41695
41777
|
}
|
|
41696
41778
|
}
|
|
41779
|
+
throttle.force({
|
|
41780
|
+
kind: "table-done",
|
|
41781
|
+
table: name,
|
|
41782
|
+
entitiesRendered: keys.length,
|
|
41783
|
+
entitiesTotal: keys.length,
|
|
41784
|
+
tableIndex: 0,
|
|
41785
|
+
tableCount: 0,
|
|
41786
|
+
pct: 100
|
|
41787
|
+
});
|
|
41697
41788
|
}
|
|
41698
41789
|
const entityContextManifest = await this._renderEntityContexts(
|
|
41699
41790
|
outputDir,
|
|
41700
41791
|
filesWritten,
|
|
41701
|
-
counters
|
|
41792
|
+
counters,
|
|
41793
|
+
throttle,
|
|
41794
|
+
signal
|
|
41702
41795
|
);
|
|
41796
|
+
if (entityContextManifest === null) {
|
|
41797
|
+
return this._abortedResult(filesWritten, counters, start);
|
|
41798
|
+
}
|
|
41703
41799
|
if (this._schema.getEntityContexts().size > 0) {
|
|
41704
41800
|
writeManifest(outputDir, {
|
|
41705
41801
|
version: 2,
|
|
@@ -41707,6 +41803,29 @@ var RenderEngine = class {
|
|
|
41707
41803
|
entityContexts: entityContextManifest
|
|
41708
41804
|
});
|
|
41709
41805
|
}
|
|
41806
|
+
const result = {
|
|
41807
|
+
filesWritten,
|
|
41808
|
+
filesSkipped: counters.skipped,
|
|
41809
|
+
durationMs: Date.now() - start
|
|
41810
|
+
};
|
|
41811
|
+
throttle.force({
|
|
41812
|
+
kind: "done",
|
|
41813
|
+
table: null,
|
|
41814
|
+
entitiesRendered: 0,
|
|
41815
|
+
entitiesTotal: 0,
|
|
41816
|
+
tableIndex: 0,
|
|
41817
|
+
tableCount: 0,
|
|
41818
|
+
pct: 100,
|
|
41819
|
+
durationMs: result.durationMs
|
|
41820
|
+
});
|
|
41821
|
+
return result;
|
|
41822
|
+
}
|
|
41823
|
+
/**
|
|
41824
|
+
* Build the partial RenderResult to return when a render is aborted. No
|
|
41825
|
+
* `done` event is emitted — the caller treats abort as "discard the partial
|
|
41826
|
+
* tree", not as a successful completion.
|
|
41827
|
+
*/
|
|
41828
|
+
_abortedResult(filesWritten, counters, start) {
|
|
41710
41829
|
return {
|
|
41711
41830
|
filesWritten,
|
|
41712
41831
|
filesSkipped: counters.skipped,
|
|
@@ -41742,127 +41861,181 @@ var RenderEngine = class {
|
|
|
41742
41861
|
/**
|
|
41743
41862
|
* Render all entity context definitions.
|
|
41744
41863
|
* Mutates `filesWritten` and `counters` in place.
|
|
41745
|
-
* Returns manifest data for the entity contexts rendered this cycle
|
|
41864
|
+
* Returns manifest data for the entity contexts rendered this cycle, or
|
|
41865
|
+
* `null` if the render was aborted mid-flight (the caller discards the
|
|
41866
|
+
* partial tree). Progress is reported through `throttle`; abort is observed
|
|
41867
|
+
* via `signal`.
|
|
41746
41868
|
*/
|
|
41747
|
-
async _renderEntityContexts(outputDir, filesWritten, counters) {
|
|
41748
|
-
const manifestData = {};
|
|
41869
|
+
async _renderEntityContexts(outputDir, filesWritten, counters, throttle, signal) {
|
|
41749
41870
|
const protectedTables = /* @__PURE__ */ new Set();
|
|
41750
41871
|
for (const [t8, d6] of this._schema.getEntityContexts()) {
|
|
41751
41872
|
if (d6.protected) protectedTables.add(t8);
|
|
41752
41873
|
}
|
|
41753
|
-
|
|
41754
|
-
|
|
41755
|
-
|
|
41756
|
-
|
|
41757
|
-
|
|
41758
|
-
|
|
41759
|
-
|
|
41760
|
-
|
|
41761
|
-
|
|
41762
|
-
|
|
41763
|
-
|
|
41764
|
-
|
|
41765
|
-
|
|
41766
|
-
|
|
41767
|
-
|
|
41768
|
-
|
|
41769
|
-
|
|
41770
|
-
|
|
41771
|
-
|
|
41772
|
-
|
|
41773
|
-
|
|
41774
|
-
const
|
|
41775
|
-
|
|
41776
|
-
|
|
41777
|
-
|
|
41778
|
-
|
|
41779
|
-
|
|
41780
|
-
|
|
41781
|
-
if (
|
|
41782
|
-
|
|
41874
|
+
const entityTables = [...this._schema.getEntityContexts()];
|
|
41875
|
+
const tableCount = entityTables.length;
|
|
41876
|
+
if (signal?.aborted) return null;
|
|
41877
|
+
const renderedEntries = await mapWithConcurrency(
|
|
41878
|
+
entityTables,
|
|
41879
|
+
RENDER_TABLE_CONCURRENCY,
|
|
41880
|
+
async ([table, def], tableIndex) => {
|
|
41881
|
+
if (signal?.aborted) return null;
|
|
41882
|
+
const entityPk = this._schema.getPrimaryKey(table)[0] ?? "id";
|
|
41883
|
+
const allRows = await this._schema.queryTable(this._adapter, table);
|
|
41884
|
+
const directoryRoot = def.directoryRoot ?? table;
|
|
41885
|
+
const entitiesTotal = allRows.length;
|
|
41886
|
+
throttle.force({
|
|
41887
|
+
kind: "table-start",
|
|
41888
|
+
table,
|
|
41889
|
+
entitiesRendered: 0,
|
|
41890
|
+
entitiesTotal,
|
|
41891
|
+
tableIndex,
|
|
41892
|
+
tableCount,
|
|
41893
|
+
pct: 0
|
|
41894
|
+
});
|
|
41895
|
+
const manifestEntry = {
|
|
41896
|
+
directoryRoot,
|
|
41897
|
+
...def.index ? { indexFile: def.index.outputFile } : {},
|
|
41898
|
+
declaredFiles: Object.keys(def.files),
|
|
41899
|
+
protectedFiles: def.protectedFiles ?? [],
|
|
41900
|
+
entities: {}
|
|
41901
|
+
};
|
|
41902
|
+
if (def.index) {
|
|
41903
|
+
const indexPath = (0, import_node_path5.join)(outputDir, def.index.outputFile);
|
|
41904
|
+
if (atomicWrite(indexPath, def.index.render(allRows))) {
|
|
41905
|
+
filesWritten.push(indexPath);
|
|
41906
|
+
} else {
|
|
41907
|
+
counters.skipped++;
|
|
41908
|
+
}
|
|
41783
41909
|
}
|
|
41784
|
-
(0
|
|
41785
|
-
|
|
41786
|
-
|
|
41787
|
-
if (
|
|
41788
|
-
|
|
41789
|
-
|
|
41790
|
-
|
|
41791
|
-
|
|
41910
|
+
for (let i6 = 0; i6 < allRows.length; i6++) {
|
|
41911
|
+
const entityRow = allRows[i6];
|
|
41912
|
+
if (signal?.aborted) return null;
|
|
41913
|
+
if (i6 > 0 && i6 % YIELD_EVERY_ENTITIES === 0) {
|
|
41914
|
+
await new Promise((r6) => setImmediate(r6));
|
|
41915
|
+
}
|
|
41916
|
+
const rawSlug = def.slug(entityRow);
|
|
41917
|
+
const slug = rawSlug.replace(/[\u00A0\u2000-\u200B\u202F\u205F\u3000]/g, " ").replace(/[\u0000-\u001F\u007F]/g, "");
|
|
41918
|
+
if (/[^a-zA-Z0-9.\-_ @(),#&'+:;!~[\]]/.test(slug)) {
|
|
41919
|
+
throw new Error(`Invalid slug "${slug}": contains characters outside the allowed set`);
|
|
41920
|
+
}
|
|
41921
|
+
const entityDir = def.directory ? (0, import_node_path5.join)(outputDir, def.directory(entityRow)) : (0, import_node_path5.join)(outputDir, directoryRoot, slug);
|
|
41922
|
+
const resolvedDir = (0, import_node_path5.resolve)(entityDir);
|
|
41923
|
+
const resolvedBase = (0, import_node_path5.resolve)(outputDir);
|
|
41924
|
+
if (!resolvedDir.startsWith(resolvedBase + import_node_path5.sep) && resolvedDir !== resolvedBase) {
|
|
41925
|
+
throw new Error(`Path traversal detected: slug "${slug}" escapes output directory`);
|
|
41926
|
+
}
|
|
41927
|
+
(0, import_node_fs4.mkdirSync)(entityDir, { recursive: true });
|
|
41928
|
+
if (def.attachFileColumn) {
|
|
41929
|
+
const filePath = entityRow[def.attachFileColumn];
|
|
41930
|
+
if (filePath && typeof filePath === "string" && filePath.length > 0) {
|
|
41931
|
+
if (def.attachFileMode === "reference") {
|
|
41932
|
+
const refPath = (0, import_node_path5.join)(entityDir, `${(0, import_node_path5.basename)(filePath)}.ref.md`);
|
|
41933
|
+
try {
|
|
41934
|
+
atomicWrite(refPath, `# Reference
|
|
41792
41935
|
|
|
41793
41936
|
- **location:** ${filePath}
|
|
41794
41937
|
`);
|
|
41795
|
-
|
|
41796
|
-
|
|
41797
|
-
|
|
41798
|
-
|
|
41799
|
-
|
|
41800
|
-
|
|
41801
|
-
|
|
41802
|
-
|
|
41803
|
-
|
|
41804
|
-
|
|
41805
|
-
|
|
41806
|
-
|
|
41938
|
+
filesWritten.push(refPath);
|
|
41939
|
+
} catch {
|
|
41940
|
+
}
|
|
41941
|
+
} else {
|
|
41942
|
+
const absPath = (0, import_node_path5.isAbsolute)(filePath) ? filePath : (0, import_node_path5.resolve)(outputDir, filePath);
|
|
41943
|
+
if ((0, import_node_fs4.existsSync)(absPath)) {
|
|
41944
|
+
const destPath = (0, import_node_path5.join)(entityDir, (0, import_node_path5.basename)(absPath));
|
|
41945
|
+
if (!(0, import_node_fs4.existsSync)(destPath)) {
|
|
41946
|
+
try {
|
|
41947
|
+
(0, import_node_fs4.copyFileSync)(absPath, destPath);
|
|
41948
|
+
filesWritten.push(destPath);
|
|
41949
|
+
} catch {
|
|
41950
|
+
}
|
|
41807
41951
|
}
|
|
41808
41952
|
}
|
|
41809
41953
|
}
|
|
41810
41954
|
}
|
|
41811
41955
|
}
|
|
41812
|
-
|
|
41813
|
-
|
|
41814
|
-
|
|
41815
|
-
|
|
41816
|
-
|
|
41817
|
-
|
|
41818
|
-
|
|
41819
|
-
|
|
41820
|
-
|
|
41821
|
-
|
|
41822
|
-
|
|
41823
|
-
|
|
41824
|
-
|
|
41825
|
-
|
|
41826
|
-
|
|
41827
|
-
|
|
41828
|
-
|
|
41829
|
-
|
|
41830
|
-
|
|
41831
|
-
|
|
41832
|
-
|
|
41833
|
-
|
|
41834
|
-
} else {
|
|
41835
|
-
counters.skipped++;
|
|
41836
|
-
}
|
|
41837
|
-
}
|
|
41838
|
-
const fileKeys = Object.keys(def.files);
|
|
41839
|
-
const effectiveCombined = def.combined ?? (fileKeys.length > 1 && renderedFiles.size > 1 ? (
|
|
41840
|
-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
41841
|
-
{ outputFile: fileKeys[0] }
|
|
41842
|
-
) : void 0);
|
|
41843
|
-
if (effectiveCombined && renderedFiles.size > 0) {
|
|
41844
|
-
const excluded = new Set(effectiveCombined.exclude ?? []);
|
|
41845
|
-
const parts = [];
|
|
41846
|
-
for (const filename of Object.keys(def.files)) {
|
|
41847
|
-
if (!excluded.has(filename) && renderedFiles.has(filename)) {
|
|
41848
|
-
parts.push(renderedFiles.get(filename) ?? "");
|
|
41849
|
-
}
|
|
41850
|
-
}
|
|
41851
|
-
if (parts.length > 0) {
|
|
41852
|
-
const combinedContent = parts.join("\n\n---\n\n");
|
|
41853
|
-
const combinedPath = (0, import_node_path5.join)(entityDir, effectiveCombined.outputFile);
|
|
41854
|
-
if (atomicWrite(combinedPath, combinedContent)) {
|
|
41855
|
-
filesWritten.push(combinedPath);
|
|
41956
|
+
const renderedFiles = /* @__PURE__ */ new Map();
|
|
41957
|
+
const entityFileHashes = {};
|
|
41958
|
+
const protection = protectedTables.size > 0 ? { protectedTables, currentTable: table } : void 0;
|
|
41959
|
+
for (const [filename, spec] of Object.entries(def.files)) {
|
|
41960
|
+
if (signal?.aborted) return null;
|
|
41961
|
+
const mergeDefaults = def.sourceDefaults && spec.source.type !== "self" && spec.source.type !== "custom" && spec.source.type !== "enriched";
|
|
41962
|
+
const source = mergeDefaults ? { ...def.sourceDefaults, ...spec.source } : spec.source;
|
|
41963
|
+
const rows = await resolveEntitySource(
|
|
41964
|
+
source,
|
|
41965
|
+
entityRow,
|
|
41966
|
+
entityPk,
|
|
41967
|
+
this._adapter,
|
|
41968
|
+
protection
|
|
41969
|
+
);
|
|
41970
|
+
if (spec.omitIfEmpty && rows.length === 0) continue;
|
|
41971
|
+
const renderFn = compileEntityRender(spec.render);
|
|
41972
|
+
const content = truncateContent(renderFn(rows), spec.budget);
|
|
41973
|
+
renderedFiles.set(filename, content);
|
|
41974
|
+
entityFileHashes[filename] = { hash: contentHash(content) };
|
|
41975
|
+
const filePath = (0, import_node_path5.join)(entityDir, filename);
|
|
41976
|
+
if (atomicWrite(filePath, content)) {
|
|
41977
|
+
filesWritten.push(filePath);
|
|
41856
41978
|
} else {
|
|
41857
41979
|
counters.skipped++;
|
|
41858
41980
|
}
|
|
41859
|
-
renderedFiles.set(effectiveCombined.outputFile, combinedContent);
|
|
41860
|
-
entityFileHashes[effectiveCombined.outputFile] = { hash: contentHash(combinedContent) };
|
|
41861
41981
|
}
|
|
41982
|
+
const fileKeys = Object.keys(def.files);
|
|
41983
|
+
const effectiveCombined = def.combined ?? (fileKeys.length > 1 && renderedFiles.size > 1 ? (
|
|
41984
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
41985
|
+
{ outputFile: fileKeys[0] }
|
|
41986
|
+
) : void 0);
|
|
41987
|
+
if (effectiveCombined && renderedFiles.size > 0) {
|
|
41988
|
+
const excluded = new Set(effectiveCombined.exclude ?? []);
|
|
41989
|
+
const parts = [];
|
|
41990
|
+
for (const filename of Object.keys(def.files)) {
|
|
41991
|
+
if (!excluded.has(filename) && renderedFiles.has(filename)) {
|
|
41992
|
+
parts.push(renderedFiles.get(filename) ?? "");
|
|
41993
|
+
}
|
|
41994
|
+
}
|
|
41995
|
+
if (parts.length > 0) {
|
|
41996
|
+
const combinedContent = parts.join("\n\n---\n\n");
|
|
41997
|
+
const combinedPath = (0, import_node_path5.join)(entityDir, effectiveCombined.outputFile);
|
|
41998
|
+
if (atomicWrite(combinedPath, combinedContent)) {
|
|
41999
|
+
filesWritten.push(combinedPath);
|
|
42000
|
+
} else {
|
|
42001
|
+
counters.skipped++;
|
|
42002
|
+
}
|
|
42003
|
+
renderedFiles.set(effectiveCombined.outputFile, combinedContent);
|
|
42004
|
+
entityFileHashes[effectiveCombined.outputFile] = {
|
|
42005
|
+
hash: contentHash(combinedContent)
|
|
42006
|
+
};
|
|
42007
|
+
}
|
|
42008
|
+
}
|
|
42009
|
+
manifestEntry.entities[slug] = entityFileHashes;
|
|
42010
|
+
const entitiesRendered = i6 + 1;
|
|
42011
|
+
throttle.tick({
|
|
42012
|
+
kind: "table-progress",
|
|
42013
|
+
table,
|
|
42014
|
+
entitiesRendered,
|
|
42015
|
+
entitiesTotal,
|
|
42016
|
+
tableIndex,
|
|
42017
|
+
tableCount,
|
|
42018
|
+
pct: entitiesTotal > 0 ? entitiesRendered / entitiesTotal * 100 : 100
|
|
42019
|
+
});
|
|
41862
42020
|
}
|
|
41863
|
-
|
|
42021
|
+
throttle.force({
|
|
42022
|
+
kind: "table-done",
|
|
42023
|
+
table,
|
|
42024
|
+
entitiesRendered: entitiesTotal,
|
|
42025
|
+
entitiesTotal,
|
|
42026
|
+
tableIndex,
|
|
42027
|
+
tableCount,
|
|
42028
|
+
pct: 100
|
|
42029
|
+
});
|
|
42030
|
+
return manifestEntry;
|
|
41864
42031
|
}
|
|
41865
|
-
|
|
42032
|
+
);
|
|
42033
|
+
if (signal?.aborted) return null;
|
|
42034
|
+
const manifestData = {};
|
|
42035
|
+
for (let i6 = 0; i6 < renderedEntries.length; i6++) {
|
|
42036
|
+
const entry = renderedEntries[i6];
|
|
42037
|
+
if (entry == null) return null;
|
|
42038
|
+
manifestData[entityTables[i6][0]] = entry;
|
|
41866
42039
|
}
|
|
41867
42040
|
return manifestData;
|
|
41868
42041
|
}
|
|
@@ -43114,14 +43287,27 @@ function buildParsedConfig(raw, sourceName, configDir2) {
|
|
|
43114
43287
|
const entityContexts = parseEntityContexts(config.entityContexts);
|
|
43115
43288
|
return name !== void 0 ? { dbPath, name, tables, entityContexts } : { dbPath, tables, entityContexts };
|
|
43116
43289
|
}
|
|
43290
|
+
function isDbRefShaped(raw) {
|
|
43291
|
+
return /^\s*\$\{LATTICE_DB:/.test(raw);
|
|
43292
|
+
}
|
|
43293
|
+
function parseDbRef(raw) {
|
|
43294
|
+
const m4 = /^\$\{LATTICE_DB:([A-Za-z0-9._-]+)\}$/.exec(raw.trim());
|
|
43295
|
+
return m4 ? { label: m4[1] ?? "" } : null;
|
|
43296
|
+
}
|
|
43117
43297
|
function resolveDbPath(raw, configDir2) {
|
|
43118
|
-
|
|
43119
|
-
|
|
43120
|
-
|
|
43121
|
-
|
|
43298
|
+
if (isDbRefShaped(raw)) {
|
|
43299
|
+
const ref = parseDbRef(raw);
|
|
43300
|
+
if (!ref) {
|
|
43301
|
+
throw new Error(
|
|
43302
|
+
`Lattice: malformed \${LATTICE_DB:\u2026} reference ${JSON.stringify(
|
|
43303
|
+
raw.trim()
|
|
43304
|
+
)} \u2014 the label may contain only [A-Za-z0-9._-] (no spaces). This usually means a workspace was created with an unsanitized name.`
|
|
43305
|
+
);
|
|
43306
|
+
}
|
|
43307
|
+
const url = getDbCredential(ref.label);
|
|
43122
43308
|
if (!url) {
|
|
43123
43309
|
throw new Error(
|
|
43124
|
-
`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}.`
|
|
43310
|
+
`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}.`
|
|
43125
43311
|
);
|
|
43126
43312
|
}
|
|
43127
43313
|
return url;
|
|
@@ -43129,6 +43315,13 @@ function resolveDbPath(raw, configDir2) {
|
|
|
43129
43315
|
if (/^postgres(ql)?:\/\//i.test(raw) || raw.startsWith("file:") || raw === ":memory:") {
|
|
43130
43316
|
return raw;
|
|
43131
43317
|
}
|
|
43318
|
+
if (raw.includes("${")) {
|
|
43319
|
+
throw new Error(
|
|
43320
|
+
`Lattice: refusing to treat ${JSON.stringify(
|
|
43321
|
+
raw.trim()
|
|
43322
|
+
)} as a database path \u2014 it looks like a malformed variable reference, not a file path.`
|
|
43323
|
+
);
|
|
43324
|
+
}
|
|
43132
43325
|
return (0, import_node_path11.resolve)(configDir2, raw);
|
|
43133
43326
|
}
|
|
43134
43327
|
var warnedDeprecatedRefs = /* @__PURE__ */ new Set();
|
|
@@ -44461,6 +44654,54 @@ var Lattice = class _Lattice {
|
|
|
44461
44654
|
async insert(table, row, provenance) {
|
|
44462
44655
|
const notInit = this._notInitError();
|
|
44463
44656
|
if (notInit) return notInit;
|
|
44657
|
+
const { sql, values, pkValue, rowWithPk } = this._prepareInsert(table, row);
|
|
44658
|
+
await runAsyncOrSync(this._adapter, sql, values);
|
|
44659
|
+
await this._afterInsert(table, pkValue, rowWithPk, provenance);
|
|
44660
|
+
return pkValue;
|
|
44661
|
+
}
|
|
44662
|
+
/**
|
|
44663
|
+
* Insert a row while atomically forcing its cloud row-visibility, regardless of
|
|
44664
|
+
* the table's `default_row_visibility`. The per-table insert trigger reads a
|
|
44665
|
+
* transaction-local GUC (`lattice.force_row_visibility`); we set it and run the
|
|
44666
|
+
* INSERT inside a single transaction, so the row is stamped at `visibility` the
|
|
44667
|
+
* instant it exists — it is never momentarily visible at the table default, and
|
|
44668
|
+
* the change-feed `NOTIFY` (delivered only at COMMIT) fires when the row already
|
|
44669
|
+
* carries this visibility. This closes the create-then-demote window that a
|
|
44670
|
+
* plain `insert()` + `setRowVisibility()` would leave open.
|
|
44671
|
+
*
|
|
44672
|
+
* Postgres-only: SQLite is single-user (no cross-viewer leak) and has no trigger
|
|
44673
|
+
* to read the GUC, so it degrades to a plain {@link insert}. A `never_share`
|
|
44674
|
+
* table still wins — its rows are forced private even if `visibility` is
|
|
44675
|
+
* `'everyone'` (the trigger enforces that precedence).
|
|
44676
|
+
*
|
|
44677
|
+
* @since 3.1.0
|
|
44678
|
+
*/
|
|
44679
|
+
async insertForcingVisibility(table, row, visibility, provenance) {
|
|
44680
|
+
const notInit = this._notInitError();
|
|
44681
|
+
if (notInit) return notInit;
|
|
44682
|
+
const vis = visibility;
|
|
44683
|
+
if (vis !== "private" && vis !== "everyone") {
|
|
44684
|
+
throw new Error(`lattice: invalid forced visibility "${vis}"`);
|
|
44685
|
+
}
|
|
44686
|
+
const withClient = this._adapter.withClient?.bind(this._adapter);
|
|
44687
|
+
if (this.getDialect() !== "postgres" || !withClient) {
|
|
44688
|
+
return this.insert(table, row, provenance);
|
|
44689
|
+
}
|
|
44690
|
+
const { sql, values, pkValue, rowWithPk } = this._prepareInsert(table, row);
|
|
44691
|
+
await withClient(async (tx) => {
|
|
44692
|
+
await tx.run(`SELECT set_config('lattice.force_row_visibility', ?, true)`, [visibility]);
|
|
44693
|
+
await tx.run(sql, values);
|
|
44694
|
+
});
|
|
44695
|
+
await this._afterInsert(table, pkValue, rowWithPk, provenance);
|
|
44696
|
+
return pkValue;
|
|
44697
|
+
}
|
|
44698
|
+
/**
|
|
44699
|
+
* Build the INSERT statement + canonical pk for a row (sanitize → schema-filter →
|
|
44700
|
+
* auto-pk → encrypt). Shared by {@link insert} and {@link insertForcingVisibility}
|
|
44701
|
+
* so both produce byte-identical writes; the latter only differs in running it
|
|
44702
|
+
* inside a GUC-scoped transaction.
|
|
44703
|
+
*/
|
|
44704
|
+
_prepareInsert(table, row) {
|
|
44464
44705
|
this._assertIdent(table);
|
|
44465
44706
|
const sanitized = this._filterToSchemaColumns(table, this._sanitizer.sanitizeRow(row));
|
|
44466
44707
|
const pkCols = this._schema.getPrimaryKey(table);
|
|
@@ -44476,12 +44717,17 @@ var Lattice = class _Lattice {
|
|
|
44476
44717
|
const cols = Object.keys(encrypted).map((c6) => `"${c6}"`).join(", ");
|
|
44477
44718
|
const placeholders = Object.keys(encrypted).map(() => "?").join(", ");
|
|
44478
44719
|
const values = Object.values(encrypted);
|
|
44479
|
-
await runAsyncOrSync(
|
|
44480
|
-
this._adapter,
|
|
44481
|
-
`INSERT INTO "${table}" (${cols}) VALUES (${placeholders})`,
|
|
44482
|
-
values
|
|
44483
|
-
);
|
|
44484
44720
|
const pkValue = this._serializeRowPk(table, rowWithPk);
|
|
44721
|
+
return {
|
|
44722
|
+
sql: `INSERT INTO "${table}" (${cols}) VALUES (${placeholders})`,
|
|
44723
|
+
values,
|
|
44724
|
+
pkValue,
|
|
44725
|
+
rowWithPk
|
|
44726
|
+
};
|
|
44727
|
+
}
|
|
44728
|
+
/** Post-insert side effects (changelog, audit, write hooks, embedding sync),
|
|
44729
|
+
* identical for the plain and force-visibility insert paths. */
|
|
44730
|
+
async _afterInsert(table, pkValue, rowWithPk, provenance) {
|
|
44485
44731
|
await this._appendChangelog(
|
|
44486
44732
|
table,
|
|
44487
44733
|
pkValue,
|
|
@@ -44495,7 +44741,6 @@ var Lattice = class _Lattice {
|
|
|
44495
44741
|
this._sanitizer.emitAudit(table, "insert", pkValue);
|
|
44496
44742
|
await this._fireWriteHooks(table, "insert", rowWithPk, pkValue);
|
|
44497
44743
|
this._syncEmbedding(table, "insert", rowWithPk, pkValue);
|
|
44498
|
-
return pkValue;
|
|
44499
44744
|
}
|
|
44500
44745
|
/**
|
|
44501
44746
|
* Insert a row and return the full inserted row (including auto-generated
|
|
@@ -44562,6 +44807,7 @@ var Lattice = class _Lattice {
|
|
|
44562
44807
|
const sanitized = this._filterToSchemaColumns(table, this._sanitizer.sanitizeRow(row));
|
|
44563
44808
|
const encrypted = this._encryptRow(table, sanitized);
|
|
44564
44809
|
const setCols = Object.keys(encrypted).map((c6) => `"${c6}" = ?`).join(", ");
|
|
44810
|
+
if (setCols === "") return;
|
|
44565
44811
|
const { clause, params: pkParams } = this._pkWhere(table, id);
|
|
44566
44812
|
let previousValues = null;
|
|
44567
44813
|
if (this._changelogTables.has(table)) {
|
|
@@ -45178,13 +45424,32 @@ var Lattice = class _Lattice {
|
|
|
45178
45424
|
// -------------------------------------------------------------------------
|
|
45179
45425
|
// Sync
|
|
45180
45426
|
// -------------------------------------------------------------------------
|
|
45181
|
-
async render(outputDir) {
|
|
45427
|
+
async render(outputDir, opts = {}) {
|
|
45182
45428
|
const notInit = this._notInitError();
|
|
45183
45429
|
if (notInit) return notInit;
|
|
45184
|
-
const result = await this._render.render(outputDir);
|
|
45430
|
+
const result = await this._render.render(outputDir, opts);
|
|
45185
45431
|
for (const h6 of this._renderHandlers) h6(result);
|
|
45186
45432
|
return result;
|
|
45187
45433
|
}
|
|
45434
|
+
/**
|
|
45435
|
+
* Render into `outputDir` through the shared single-flight guard, intended to
|
|
45436
|
+
* be called fire-and-forget (e.g. the GUI's instant-open background render).
|
|
45437
|
+
*
|
|
45438
|
+
* The guard ({@link _renderGuarded}) holds {@link _autoRenderInFlight} for the
|
|
45439
|
+
* render's duration, so a data mutation that lands while this render is in
|
|
45440
|
+
* flight is deferred by {@link _runAutoRender} and coalesced — when this
|
|
45441
|
+
* render settles, `finally` clears the flag and re-arms exactly one follow-up
|
|
45442
|
+
* render via {@link _rearmAutoRenderIfPending}. Net invariant: at most one
|
|
45443
|
+
* render to a given dir at a time.
|
|
45444
|
+
*
|
|
45445
|
+
* Errors propagate to the caller (the GUI surfaces them, never silently swallowed); they are
|
|
45446
|
+
* not swallowed here.
|
|
45447
|
+
*/
|
|
45448
|
+
async renderInBackground(outputDir, opts = {}) {
|
|
45449
|
+
const notInit = this._notInitError();
|
|
45450
|
+
if (notInit) return notInit;
|
|
45451
|
+
return this._renderGuarded(outputDir, opts);
|
|
45452
|
+
}
|
|
45188
45453
|
async sync(outputDir) {
|
|
45189
45454
|
const notInit = this._notInitError();
|
|
45190
45455
|
if (notInit) return notInit;
|
|
@@ -45526,6 +45791,30 @@ var Lattice = class _Lattice {
|
|
|
45526
45791
|
}, this._autoRenderDebounceMs);
|
|
45527
45792
|
this._autoRenderTimer.unref();
|
|
45528
45793
|
}
|
|
45794
|
+
/**
|
|
45795
|
+
* Shared single-flight render path used by {@link renderInBackground}.
|
|
45796
|
+
*
|
|
45797
|
+
* Holds {@link _autoRenderInFlight} for the render's duration so the
|
|
45798
|
+
* mutation-driven {@link _runAutoRender} defers while this render runs (it
|
|
45799
|
+
* sees the flag and marks itself pending instead of starting a second,
|
|
45800
|
+
* overlapping render). On settle, `finally` clears the flag and re-arms a
|
|
45801
|
+
* single coalesced follow-up render if any mutation arrived mid-flight.
|
|
45802
|
+
* Errors propagate to the caller; the flag is always cleared.
|
|
45803
|
+
*/
|
|
45804
|
+
async _renderGuarded(outputDir, opts) {
|
|
45805
|
+
while (this._autoRenderInFlight) {
|
|
45806
|
+
await new Promise((r6) => setImmediate(r6));
|
|
45807
|
+
}
|
|
45808
|
+
this._autoRenderInFlight = true;
|
|
45809
|
+
try {
|
|
45810
|
+
const result = await this._render.render(outputDir, opts);
|
|
45811
|
+
for (const h6 of this._renderHandlers) h6(result);
|
|
45812
|
+
return result;
|
|
45813
|
+
} finally {
|
|
45814
|
+
this._autoRenderInFlight = false;
|
|
45815
|
+
this._rearmAutoRenderIfPending();
|
|
45816
|
+
}
|
|
45817
|
+
}
|
|
45529
45818
|
async _runAutoRender() {
|
|
45530
45819
|
const dir = this._autoRenderDir;
|
|
45531
45820
|
if (!dir || !this._initialized) return;
|
|
@@ -47086,7 +47375,7 @@ function archiveLocalSqlite(dbPath) {
|
|
|
47086
47375
|
async function cloudRlsInstalled(probe) {
|
|
47087
47376
|
const row = await getAsyncOrSync(
|
|
47088
47377
|
probe.adapter,
|
|
47089
|
-
`SELECT to_regclass('
|
|
47378
|
+
`SELECT to_regclass('__lattice_owners') AS reg`
|
|
47090
47379
|
);
|
|
47091
47380
|
return !!row && row.reg != null;
|
|
47092
47381
|
}
|
|
@@ -47143,6 +47432,19 @@ function isPostgresUrl(url) {
|
|
|
47143
47432
|
}
|
|
47144
47433
|
|
|
47145
47434
|
// src/cloud/rls.ts
|
|
47435
|
+
async function runCloudBootstrapSql(db, sql) {
|
|
47436
|
+
const adapter = db.adapter;
|
|
47437
|
+
if (adapter.withClient) {
|
|
47438
|
+
await adapter.withClient(async (tx) => {
|
|
47439
|
+
await tx.run("SELECT pg_advisory_xact_lock($1::bigint)", [
|
|
47440
|
+
LATTICE_MIGRATION_LOCK_ID.toString()
|
|
47441
|
+
]);
|
|
47442
|
+
await tx.run(sql);
|
|
47443
|
+
});
|
|
47444
|
+
} else {
|
|
47445
|
+
await runAsyncOrSync(adapter, sql);
|
|
47446
|
+
}
|
|
47447
|
+
}
|
|
47146
47448
|
function isPg(db) {
|
|
47147
47449
|
return db.getDialect() === "postgres";
|
|
47148
47450
|
}
|
|
@@ -47153,6 +47455,31 @@ function pkSqlExpr(pkCols, prefix) {
|
|
|
47153
47455
|
return pkCols.map((c6) => `CAST(${prefix}"${c6}" AS TEXT)`).join(` || chr(9) || `);
|
|
47154
47456
|
}
|
|
47155
47457
|
var MEMBER_GROUP = "lattice_members";
|
|
47458
|
+
function pinDefinerSearchPath(sql, schema) {
|
|
47459
|
+
const safe = schema.replace(/"/g, '""');
|
|
47460
|
+
return sql.replace(
|
|
47461
|
+
/SECURITY DEFINER AS/g,
|
|
47462
|
+
`SECURITY DEFINER SET search_path = "${safe}", pg_temp AS`
|
|
47463
|
+
);
|
|
47464
|
+
}
|
|
47465
|
+
async function cloudSchema(db) {
|
|
47466
|
+
const row = await getAsyncOrSync(db.adapter, `SELECT current_schema() AS schema`);
|
|
47467
|
+
const s2 = row?.schema;
|
|
47468
|
+
if (typeof s2 !== "string" || s2.length === 0) {
|
|
47469
|
+
throw new Error("cloud RLS: could not resolve current_schema() for search_path pinning");
|
|
47470
|
+
}
|
|
47471
|
+
return s2;
|
|
47472
|
+
}
|
|
47473
|
+
function revokeSchemaCreateSql(schema) {
|
|
47474
|
+
const lit = `'${schema.replace(/'/g, "''")}'`;
|
|
47475
|
+
return `
|
|
47476
|
+
DO $LATTICE_REVOKE$ BEGIN
|
|
47477
|
+
EXECUTE format('REVOKE CREATE ON SCHEMA %I FROM PUBLIC', ${lit});
|
|
47478
|
+
EXCEPTION WHEN OTHERS THEN
|
|
47479
|
+
NULL; -- not the schema owner, or already revoked
|
|
47480
|
+
END $LATTICE_REVOKE$;
|
|
47481
|
+
`;
|
|
47482
|
+
}
|
|
47156
47483
|
var CLOUD_RLS_BOOTSTRAP_SQL = `
|
|
47157
47484
|
-- Member group (NOLOGIN). Members inherit schema/connect/table privileges from it;
|
|
47158
47485
|
-- RLS filters per the individual member's login role, so the group never widens
|
|
@@ -47212,6 +47539,82 @@ CREATE TABLE IF NOT EXISTS "__lattice_cell_grants" (
|
|
|
47212
47539
|
PRIMARY KEY ("table_name", "pk", "column_name", "grantee_role")
|
|
47213
47540
|
);
|
|
47214
47541
|
|
|
47542
|
+
-- Per-table policy: the owner-controlled defaults that govern a whole table.
|
|
47543
|
+
-- default_row_visibility is the visibility NEW rows are stamped with (the insert
|
|
47544
|
+
-- trigger reads it); never_share is a hard exclusion \u2014 the share/grant functions
|
|
47545
|
+
-- refuse to elevate such a table and the trigger forces its rows private. Owner-
|
|
47546
|
+
-- managed; members have no grant (it never appears in their data API).
|
|
47547
|
+
CREATE TABLE IF NOT EXISTS "__lattice_table_policy" (
|
|
47548
|
+
"table_name" text PRIMARY KEY,
|
|
47549
|
+
"default_row_visibility" text NOT NULL DEFAULT 'private'
|
|
47550
|
+
CHECK ("default_row_visibility" IN ('private','everyone')),
|
|
47551
|
+
"never_share" boolean NOT NULL DEFAULT false,
|
|
47552
|
+
"updated_by" text NOT NULL DEFAULT session_user,
|
|
47553
|
+
"updated_at" timestamptz NOT NULL DEFAULT now()
|
|
47554
|
+
);
|
|
47555
|
+
|
|
47556
|
+
-- Per-column audience policy: the CANONICAL store of which column carries which
|
|
47557
|
+
-- audience spec (role: / subject: / source: / owner / everyone). Previously the
|
|
47558
|
+
-- spec lived only in the owner's on-disk YAML and was compiled into the mask view
|
|
47559
|
+
-- once at init; storing it here makes it cloud-canonical and member-consistent.
|
|
47560
|
+
-- The generated <table>_v mask view is regenerated from THIS table on change.
|
|
47561
|
+
-- Owner-managed; members have no grant.
|
|
47562
|
+
CREATE TABLE IF NOT EXISTS "__lattice_column_policy" (
|
|
47563
|
+
"table_name" text NOT NULL,
|
|
47564
|
+
"column_name" text NOT NULL,
|
|
47565
|
+
"audience" text NOT NULL,
|
|
47566
|
+
"updated_by" text NOT NULL DEFAULT session_user,
|
|
47567
|
+
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
|
47568
|
+
PRIMARY KEY ("table_name", "column_name")
|
|
47569
|
+
);
|
|
47570
|
+
|
|
47571
|
+
-- Owner-only audit of issued member invites: which scoped role was minted for
|
|
47572
|
+
-- which email (HASHED \u2014 the plaintext email is never stored), when it expires,
|
|
47573
|
+
-- and whether it was redeemed/revoked. No plaintext password is ever stored
|
|
47574
|
+
-- (the credential lives only inside the email-bound token the owner delivers).
|
|
47575
|
+
-- Owner-managed; members have no grant. Named distinctly from any legacy
|
|
47576
|
+
-- team-model invitations table so a pre-existing cloud never collides.
|
|
47577
|
+
CREATE TABLE IF NOT EXISTS "__lattice_member_invites" (
|
|
47578
|
+
"id" text PRIMARY KEY,
|
|
47579
|
+
"role" text NOT NULL,
|
|
47580
|
+
"email_hash" text NOT NULL,
|
|
47581
|
+
"email" text,
|
|
47582
|
+
"created_by" text NOT NULL DEFAULT session_user,
|
|
47583
|
+
"created_at" timestamptz NOT NULL DEFAULT now(),
|
|
47584
|
+
"expires_at" timestamptz NOT NULL,
|
|
47585
|
+
"redeemed_at" timestamptz,
|
|
47586
|
+
"revoked_at" timestamptz
|
|
47587
|
+
);
|
|
47588
|
+
-- Plaintext invitee email (owner-only table; members have no grant) so the
|
|
47589
|
+
-- owner's Members list can show who each member is. Added via ALTER so clouds
|
|
47590
|
+
-- created before this column converge to it on the owner's next open (the
|
|
47591
|
+
-- bootstrap is now run directly + idempotently, not version-gated).
|
|
47592
|
+
ALTER TABLE "__lattice_member_invites" ADD COLUMN IF NOT EXISTS "email" text;
|
|
47593
|
+
|
|
47594
|
+
-- #3.1 \u2014 one-time-use + revocation enforcement. After a member authenticates to
|
|
47595
|
+
-- the cloud with their minted credential, the join path calls this to CLAIM the
|
|
47596
|
+
-- invite. The single atomic UPDATE stamps redeemed_at and returns true ONLY when
|
|
47597
|
+
-- an invite for the CALLING role (session_user) is still pending: not already
|
|
47598
|
+
-- redeemed (one-time-use), not revoked, and not expired. A replayed redeem of a
|
|
47599
|
+
-- leaked token, a revoked invite, or an expired one returns false, so the caller
|
|
47600
|
+
-- rejects the join. Members have no direct grant on the owner-only
|
|
47601
|
+
-- __lattice_member_invites table \u2014 this SECURITY DEFINER function is the only
|
|
47602
|
+
-- path, and it can claim ONLY the caller's own invite (keyed on session_user,
|
|
47603
|
+
-- never a caller-supplied parameter, so one member can't burn another's invite).
|
|
47604
|
+
CREATE OR REPLACE FUNCTION lattice_claim_invite()
|
|
47605
|
+
RETURNS boolean LANGUAGE plpgsql SECURITY DEFINER AS $fn$
|
|
47606
|
+
DECLARE v_ok boolean;
|
|
47607
|
+
BEGIN
|
|
47608
|
+
UPDATE "__lattice_member_invites"
|
|
47609
|
+
SET "redeemed_at" = now()
|
|
47610
|
+
WHERE "role" = session_user
|
|
47611
|
+
AND "redeemed_at" IS NULL
|
|
47612
|
+
AND "revoked_at" IS NULL
|
|
47613
|
+
AND "expires_at" > now()
|
|
47614
|
+
RETURNING true INTO v_ok;
|
|
47615
|
+
RETURN COALESCE(v_ok, false);
|
|
47616
|
+
END $fn$;
|
|
47617
|
+
|
|
47215
47618
|
-- Visibility check. SECURITY DEFINER so it reads bookkeeping the member can't;
|
|
47216
47619
|
-- keyed on session_user (the member's login role). A row with no ownership record
|
|
47217
47620
|
-- is visible to nobody.
|
|
@@ -47237,6 +47640,10 @@ BEGIN
|
|
|
47237
47640
|
IF p_visibility NOT IN ('private','everyone','custom') THEN
|
|
47238
47641
|
RAISE EXCEPTION 'lattice: invalid visibility %', p_visibility;
|
|
47239
47642
|
END IF;
|
|
47643
|
+
IF p_visibility <> 'private'
|
|
47644
|
+
AND COALESCE((SELECT "never_share" FROM "__lattice_table_policy" WHERE "table_name" = p_table), false) THEN
|
|
47645
|
+
RAISE EXCEPTION 'lattice: "%" is a private-only table and cannot be shared', p_table;
|
|
47646
|
+
END IF;
|
|
47240
47647
|
SELECT o."owner_role" INTO v_owner FROM "__lattice_owners" o
|
|
47241
47648
|
WHERE o."table_name" = p_table AND o."pk" = p_pk;
|
|
47242
47649
|
IF v_owner IS NULL THEN RAISE EXCEPTION 'lattice: no ownership record for %/%', p_table, p_pk; END IF;
|
|
@@ -47250,6 +47657,9 @@ CREATE OR REPLACE FUNCTION lattice_grant_row(p_table text, p_pk text, p_grantee
|
|
|
47250
47657
|
RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $fn$
|
|
47251
47658
|
DECLARE v_owner text;
|
|
47252
47659
|
BEGIN
|
|
47660
|
+
IF COALESCE((SELECT "never_share" FROM "__lattice_table_policy" WHERE "table_name" = p_table), false) THEN
|
|
47661
|
+
RAISE EXCEPTION 'lattice: "%" is a private-only table and cannot be shared', p_table;
|
|
47662
|
+
END IF;
|
|
47253
47663
|
SELECT o."owner_role" INTO v_owner FROM "__lattice_owners" o
|
|
47254
47664
|
WHERE o."table_name" = p_table AND o."pk" = p_pk;
|
|
47255
47665
|
IF v_owner IS NULL THEN RAISE EXCEPTION 'lattice: no ownership record for %/%', p_table, p_pk; END IF;
|
|
@@ -47339,6 +47749,9 @@ CREATE OR REPLACE FUNCTION lattice_grant_cell(p_table text, p_pk text, p_column
|
|
|
47339
47749
|
RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $fn$
|
|
47340
47750
|
DECLARE v_owner text;
|
|
47341
47751
|
BEGIN
|
|
47752
|
+
IF COALESCE((SELECT "never_share" FROM "__lattice_table_policy" WHERE "table_name" = p_table), false) THEN
|
|
47753
|
+
RAISE EXCEPTION 'lattice: "%" is a private-only table and cannot be shared', p_table;
|
|
47754
|
+
END IF;
|
|
47342
47755
|
SELECT o."owner_role" INTO v_owner FROM "__lattice_owners" o
|
|
47343
47756
|
WHERE o."table_name" = p_table AND o."pk" = p_pk;
|
|
47344
47757
|
IF v_owner IS NULL OR v_owner <> session_user THEN
|
|
@@ -47371,6 +47784,87 @@ RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER AS $fn$
|
|
|
47371
47784
|
SELECT lattice_row_visible('files', p_source_ref)
|
|
47372
47785
|
$fn$;
|
|
47373
47786
|
|
|
47787
|
+
-- Is the connected member the OWNER of this row? Used by the "owner" column
|
|
47788
|
+
-- audience (a secret column reveals only to the row owner). SECURITY DEFINER +
|
|
47789
|
+
-- session_user, like the other predicates.
|
|
47790
|
+
CREATE OR REPLACE FUNCTION lattice_is_owner(p_table text, p_pk text)
|
|
47791
|
+
RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER AS $fn$
|
|
47792
|
+
SELECT EXISTS (
|
|
47793
|
+
SELECT 1 FROM "__lattice_owners" o
|
|
47794
|
+
WHERE o."table_name" = p_table AND o."pk" = p_pk AND o."owner_role" = session_user
|
|
47795
|
+
)
|
|
47796
|
+
$fn$;
|
|
47797
|
+
|
|
47798
|
+
-- Owner-only: set a table's default row visibility for NEW rows. Raises unless the
|
|
47799
|
+
-- caller can create roles (a cloud owner / DBA), like lattice_assign_role. Rejects
|
|
47800
|
+
-- 'everyone' on a never-share table.
|
|
47801
|
+
CREATE OR REPLACE FUNCTION lattice_set_table_default_visibility(p_table text, p_visibility text)
|
|
47802
|
+
RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $fn$
|
|
47803
|
+
BEGIN
|
|
47804
|
+
IF NOT (SELECT rolcreaterole FROM pg_roles WHERE rolname = session_user) THEN
|
|
47805
|
+
RAISE EXCEPTION 'lattice: only a cloud owner may set a table''s default visibility';
|
|
47806
|
+
END IF;
|
|
47807
|
+
IF p_visibility NOT IN ('private','everyone') THEN
|
|
47808
|
+
RAISE EXCEPTION 'lattice: invalid default visibility %', p_visibility;
|
|
47809
|
+
END IF;
|
|
47810
|
+
IF p_visibility = 'everyone'
|
|
47811
|
+
AND COALESCE((SELECT "never_share" FROM "__lattice_table_policy" WHERE "table_name" = p_table), false) THEN
|
|
47812
|
+
RAISE EXCEPTION 'lattice: "%" is a private-only table; its rows cannot default to everyone', p_table;
|
|
47813
|
+
END IF;
|
|
47814
|
+
INSERT INTO "__lattice_table_policy" ("table_name","default_row_visibility","updated_by","updated_at")
|
|
47815
|
+
VALUES (p_table, p_visibility, session_user, now())
|
|
47816
|
+
ON CONFLICT ("table_name") DO UPDATE
|
|
47817
|
+
SET "default_row_visibility" = EXCLUDED."default_row_visibility",
|
|
47818
|
+
"updated_by" = session_user, "updated_at" = now();
|
|
47819
|
+
END $fn$;
|
|
47820
|
+
|
|
47821
|
+
-- Owner-only: mark a table never-shareable (Secrets/Messages-class). When true the
|
|
47822
|
+
-- share/grant functions raise and the insert trigger forces new rows private; the
|
|
47823
|
+
-- default visibility is also forced private. Turning it ON also RETROACTIVELY
|
|
47824
|
+
-- privatizes the table: any row currently shared ('everyone'/'custom') is reset to
|
|
47825
|
+
-- 'private' and every existing row/cell grant on the table is dropped \u2014 otherwise
|
|
47826
|
+
-- flagging a table never-share would leave already-leaked rows visible, defeating
|
|
47827
|
+
-- the point. Idempotent: re-running with already-private rows updates nothing.
|
|
47828
|
+
CREATE OR REPLACE FUNCTION lattice_set_table_never_share(p_table text, p_on boolean)
|
|
47829
|
+
RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $fn$
|
|
47830
|
+
BEGIN
|
|
47831
|
+
IF NOT (SELECT rolcreaterole FROM pg_roles WHERE rolname = session_user) THEN
|
|
47832
|
+
RAISE EXCEPTION 'lattice: only a cloud owner may change a table''s never-share flag';
|
|
47833
|
+
END IF;
|
|
47834
|
+
INSERT INTO "__lattice_table_policy" ("table_name","never_share","default_row_visibility","updated_by","updated_at")
|
|
47835
|
+
VALUES (p_table, p_on, CASE WHEN p_on THEN 'private' ELSE 'private' END, session_user, now())
|
|
47836
|
+
ON CONFLICT ("table_name") DO UPDATE
|
|
47837
|
+
SET "never_share" = EXCLUDED."never_share",
|
|
47838
|
+
"default_row_visibility" = CASE WHEN EXCLUDED."never_share"
|
|
47839
|
+
THEN 'private' ELSE "__lattice_table_policy"."default_row_visibility" END,
|
|
47840
|
+
"updated_by" = session_user, "updated_at" = now();
|
|
47841
|
+
IF p_on THEN
|
|
47842
|
+
UPDATE "__lattice_owners" SET "visibility" = 'private', "updated_at" = now()
|
|
47843
|
+
WHERE "table_name" = p_table AND "visibility" <> 'private';
|
|
47844
|
+
DELETE FROM "__lattice_row_grants" WHERE "table_name" = p_table;
|
|
47845
|
+
DELETE FROM "__lattice_cell_grants" WHERE "table_name" = p_table;
|
|
47846
|
+
END IF;
|
|
47847
|
+
END $fn$;
|
|
47848
|
+
|
|
47849
|
+
-- Owner-only: set (or clear) a column's audience spec in the canonical DB store.
|
|
47850
|
+
-- An empty/null spec removes the policy row (column becomes unmasked). The GUI/lib
|
|
47851
|
+
-- regenerates the table's mask view from this store after calling this.
|
|
47852
|
+
CREATE OR REPLACE FUNCTION lattice_set_column_audience(p_table text, p_column text, p_audience text)
|
|
47853
|
+
RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $fn$
|
|
47854
|
+
BEGIN
|
|
47855
|
+
IF NOT (SELECT rolcreaterole FROM pg_roles WHERE rolname = session_user) THEN
|
|
47856
|
+
RAISE EXCEPTION 'lattice: only a cloud owner may set a column audience';
|
|
47857
|
+
END IF;
|
|
47858
|
+
IF p_audience IS NULL OR btrim(p_audience) = '' THEN
|
|
47859
|
+
DELETE FROM "__lattice_column_policy" WHERE "table_name" = p_table AND "column_name" = p_column;
|
|
47860
|
+
ELSE
|
|
47861
|
+
INSERT INTO "__lattice_column_policy" ("table_name","column_name","audience","updated_by","updated_at")
|
|
47862
|
+
VALUES (p_table, p_column, p_audience, session_user, now())
|
|
47863
|
+
ON CONFLICT ("table_name","column_name") DO UPDATE
|
|
47864
|
+
SET "audience" = EXCLUDED."audience", "updated_by" = session_user, "updated_at" = now();
|
|
47865
|
+
END IF;
|
|
47866
|
+
END $fn$;
|
|
47867
|
+
|
|
47374
47868
|
-- Append-only change feed. The per-table ownership trigger records one row per
|
|
47375
47869
|
-- INSERT/UPDATE/DELETE; the AFTER INSERT trigger here fires pg_notify so a
|
|
47376
47870
|
-- connected member's realtime broker refreshes. Members get no direct access \u2014
|
|
@@ -47402,6 +47896,62 @@ END $fn$;
|
|
|
47402
47896
|
DROP TRIGGER IF EXISTS "lattice_notify_change_trg" ON "__lattice_changes";
|
|
47403
47897
|
CREATE TRIGGER "lattice_notify_change_trg" AFTER INSERT ON "__lattice_changes"
|
|
47404
47898
|
FOR EACH ROW EXECUTE FUNCTION lattice_notify_change();
|
|
47899
|
+
|
|
47900
|
+
-- #4.4 \u2014 seq-based catch-up after a realtime gap. NOTIFY is fire-and-forget, so a
|
|
47901
|
+
-- broker that drops its LISTEN (network blip, laptop sleep) misses every change
|
|
47902
|
+
-- during the gap. The broker tracks the highest seq it delivered and, on
|
|
47903
|
+
-- reconnect, replays what it missed via this function. Members have NO direct
|
|
47904
|
+
-- grant on __lattice_changes (reading it raw would leak every change on the
|
|
47905
|
+
-- cloud), so this SECURITY DEFINER function is the only path and it returns ONLY
|
|
47906
|
+
-- the rows the CALLING role can see: keyed on session_user via lattice_row_visible
|
|
47907
|
+
-- (same gate as live fan-out, #4.3). Deletes are excluded \u2014 the ownership record
|
|
47908
|
+
-- is gone post-delete so visibility can't be verified, and replaying them would
|
|
47909
|
+
-- leak deleted-row pks (the client reconciles deletes on its reconnect refetch).
|
|
47910
|
+
-- Bounded (LIMIT clamped \u2264 1000) so a long gap can't stream the whole table (Rule:
|
|
47911
|
+
-- bounded reads on a hot path).
|
|
47912
|
+
CREATE OR REPLACE FUNCTION lattice_changes_since(p_seq bigint, p_limit int)
|
|
47913
|
+
RETURNS TABLE(seq bigint, table_name text, pk text, op text, owner_role text, created_at timestamptz)
|
|
47914
|
+
LANGUAGE sql STABLE SECURITY DEFINER AS $fn$
|
|
47915
|
+
SELECT c."seq", c."table_name", c."pk", c."op", c."owner_role", c."created_at"
|
|
47916
|
+
FROM "__lattice_changes" c
|
|
47917
|
+
WHERE c."seq" > p_seq
|
|
47918
|
+
AND c."op" = 'upsert'
|
|
47919
|
+
AND lattice_row_visible(c."table_name", c."pk")
|
|
47920
|
+
ORDER BY c."seq" ASC
|
|
47921
|
+
LIMIT GREATEST(0, LEAST(COALESCE(p_limit, 500), 1000));
|
|
47922
|
+
$fn$;
|
|
47923
|
+
|
|
47924
|
+
-- #2.1 \u2014 per-row access summary for the connecting role. The GUI attaches this as
|
|
47925
|
+
-- each row's _access so the sharing affordance renders, but __lattice_owners is
|
|
47926
|
+
-- owner-only bookkeeping (members have no grant), so a member reading it directly
|
|
47927
|
+
-- got "permission denied". This SECURITY DEFINER function returns visibility +
|
|
47928
|
+
-- whether the CALLER owns the row, ONLY for the rows the caller can actually see
|
|
47929
|
+
-- (lattice_row_visible, keyed on session_user) \u2014 so a member learns nothing about
|
|
47930
|
+
-- rows hidden from it. Member-callable; the owner gets the same view of its rows.
|
|
47931
|
+
CREATE OR REPLACE FUNCTION lattice_rows_access(p_table text, p_pks text[])
|
|
47932
|
+
RETURNS TABLE(pk text, visibility text, owned boolean)
|
|
47933
|
+
LANGUAGE sql STABLE SECURITY DEFINER AS $fn$
|
|
47934
|
+
SELECT o."pk", o."visibility", (o."owner_role" = session_user) AS owned
|
|
47935
|
+
FROM "__lattice_owners" o
|
|
47936
|
+
WHERE o."table_name" = p_table
|
|
47937
|
+
AND o."pk" = ANY(p_pks)
|
|
47938
|
+
AND lattice_row_visible(o."table_name", o."pk");
|
|
47939
|
+
$fn$;
|
|
47940
|
+
|
|
47941
|
+
-- #2.1 \u2014 grantees of a CALLER-OWNED custom-shared row (who you shared YOUR row
|
|
47942
|
+
-- with). Only the row owner sees this (the WHERE pins owner_role = session_user),
|
|
47943
|
+
-- so a member can't enumerate another owner's grants. __lattice_row_grants is
|
|
47944
|
+
-- member-ungranted, so this SECURITY DEFINER function is the member-safe path.
|
|
47945
|
+
CREATE OR REPLACE FUNCTION lattice_row_grantees(p_table text, p_pks text[])
|
|
47946
|
+
RETURNS TABLE(pk text, grantee_role text)
|
|
47947
|
+
LANGUAGE sql STABLE SECURITY DEFINER AS $fn$
|
|
47948
|
+
SELECT g."pk", g."grantee_role"
|
|
47949
|
+
FROM "__lattice_row_grants" g
|
|
47950
|
+
JOIN "__lattice_owners" o ON o."table_name" = g."table_name" AND o."pk" = g."pk"
|
|
47951
|
+
WHERE g."table_name" = p_table
|
|
47952
|
+
AND g."pk" = ANY(p_pks)
|
|
47953
|
+
AND o."owner_role" = session_user;
|
|
47954
|
+
$fn$;
|
|
47405
47955
|
`;
|
|
47406
47956
|
function tableRlsSql(table, pkCols) {
|
|
47407
47957
|
const q3 = `"${table.replace(/"/g, '""')}"`;
|
|
@@ -47415,7 +47965,20 @@ CREATE OR REPLACE FUNCTION "${trg}"() RETURNS trigger LANGUAGE plpgsql SECURITY
|
|
|
47415
47965
|
BEGIN
|
|
47416
47966
|
IF TG_OP = 'INSERT' THEN
|
|
47417
47967
|
INSERT INTO "__lattice_owners" ("table_name","pk","owner_role","visibility")
|
|
47418
|
-
VALUES (${lit}, ${pkNew}, session_user,
|
|
47968
|
+
VALUES (${lit}, ${pkNew}, session_user,
|
|
47969
|
+
CASE
|
|
47970
|
+
-- never-share always wins: such a table's rows are private, full stop.
|
|
47971
|
+
WHEN COALESCE((SELECT "never_share" FROM "__lattice_table_policy" WHERE "table_name" = ${lit}), false)
|
|
47972
|
+
THEN 'private'
|
|
47973
|
+
-- per-INSERT override: a caller forcing visibility for THIS write (e.g.
|
|
47974
|
+
-- chat "private mode") sets the transaction-local lattice.force_row_visibility
|
|
47975
|
+
-- GUC, so the row is stamped atomically at insert \u2014 never momentarily at
|
|
47976
|
+
-- the table default, and the change-feed NOTIFY (deferred to COMMIT) only
|
|
47977
|
+
-- fires once the row already carries this visibility.
|
|
47978
|
+
WHEN NULLIF(current_setting('lattice.force_row_visibility', true), '') IN ('private','everyone')
|
|
47979
|
+
THEN current_setting('lattice.force_row_visibility', true)
|
|
47980
|
+
ELSE COALESCE((SELECT "default_row_visibility" FROM "__lattice_table_policy" WHERE "table_name" = ${lit}), 'private')
|
|
47981
|
+
END)
|
|
47419
47982
|
ON CONFLICT ("table_name","pk") DO NOTHING;
|
|
47420
47983
|
INSERT INTO "__lattice_changes" ("table_name","pk","op","owner_role")
|
|
47421
47984
|
VALUES (${lit}, ${pkNew}, 'upsert', session_user);
|
|
@@ -47455,20 +48018,15 @@ CREATE TRIGGER "${trg}" AFTER INSERT OR UPDATE OR DELETE ON ${q3}
|
|
|
47455
48018
|
}
|
|
47456
48019
|
async function installCloudRls(db) {
|
|
47457
48020
|
if (!isPg(db)) return;
|
|
47458
|
-
const
|
|
47459
|
-
|
|
47460
|
-
|
|
47461
|
-
// The bootstrap is fully idempotent (CREATE OR REPLACE / IF NOT EXISTS).
|
|
47462
|
-
version: "internal:cloud-rls:bootstrap:v5",
|
|
47463
|
-
sql: CLOUD_RLS_BOOTSTRAP_SQL
|
|
47464
|
-
};
|
|
47465
|
-
await db.migrate([migration]);
|
|
48021
|
+
const schema = await cloudSchema(db);
|
|
48022
|
+
const sql = pinDefinerSearchPath(CLOUD_RLS_BOOTSTRAP_SQL, schema) + revokeSchemaCreateSql(schema);
|
|
48023
|
+
await runCloudBootstrapSql(db, sql);
|
|
47466
48024
|
}
|
|
47467
48025
|
async function enableChangelogRls(db) {
|
|
47468
48026
|
if (!isPg(db)) return;
|
|
47469
|
-
|
|
47470
|
-
|
|
47471
|
-
|
|
48027
|
+
await runCloudBootstrapSql(
|
|
48028
|
+
db,
|
|
48029
|
+
`
|
|
47472
48030
|
ALTER TABLE "__lattice_changelog" ENABLE ROW LEVEL SECURITY;
|
|
47473
48031
|
ALTER TABLE "__lattice_changelog" FORCE ROW LEVEL SECURITY;
|
|
47474
48032
|
GRANT SELECT, INSERT ON "__lattice_changelog" TO ${MEMBER_GROUP};
|
|
@@ -47478,24 +48036,25 @@ CREATE POLICY "lattice_changelog_sel" ON "__lattice_changelog" FOR SELECT USING
|
|
|
47478
48036
|
CASE
|
|
47479
48037
|
WHEN "change_kind" = 'derived' THEN
|
|
47480
48038
|
"source_ref" IS NOT NULL
|
|
48039
|
+
AND jsonb_array_length("source_ref"::jsonb) > 0
|
|
47481
48040
|
AND NOT EXISTS (
|
|
47482
48041
|
SELECT 1 FROM jsonb_array_elements_text("source_ref"::jsonb) AS src(sid)
|
|
47483
48042
|
WHERE NOT lattice_source_visible(src.sid)
|
|
47484
48043
|
)
|
|
47485
|
-
ELSE
|
|
48044
|
+
ELSE lattice_is_owner("table_name", "row_id")
|
|
47486
48045
|
END
|
|
47487
48046
|
);
|
|
47488
48047
|
DROP POLICY IF EXISTS "lattice_changelog_ins" ON "__lattice_changelog";
|
|
47489
48048
|
CREATE POLICY "lattice_changelog_ins" ON "__lattice_changelog" FOR INSERT WITH CHECK (true);
|
|
47490
48049
|
`
|
|
47491
|
-
|
|
47492
|
-
await db.migrate([migration]);
|
|
48050
|
+
);
|
|
47493
48051
|
}
|
|
47494
48052
|
async function enableRlsForTable(db, table, pkCols) {
|
|
47495
48053
|
if (!isPg(db)) return;
|
|
48054
|
+
const schema = await cloudSchema(db);
|
|
47496
48055
|
const migration = {
|
|
47497
|
-
version: `internal:cloud-rls:table:${table}:
|
|
47498
|
-
sql: tableRlsSql(table, pkCols)
|
|
48056
|
+
version: `internal:cloud-rls:table:${table}:v3`,
|
|
48057
|
+
sql: pinDefinerSearchPath(tableRlsSql(table, pkCols), schema)
|
|
47499
48058
|
};
|
|
47500
48059
|
await db.migrate([migration]);
|
|
47501
48060
|
}
|
|
@@ -47542,7 +48101,12 @@ async function provisionMemberRole(db, role, password) {
|
|
|
47542
48101
|
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${role}') THEN
|
|
47543
48102
|
CREATE ROLE "${role}" LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE PASSWORD '${password}';
|
|
47544
48103
|
ELSE
|
|
47545
|
-
|
|
48104
|
+
-- Re-invite of an EXISTING role: set ONLY what changed (login + password).
|
|
48105
|
+
-- Restating NOSUPERUSER/superuser-class attrs trips Supabase supautils
|
|
48106
|
+
-- ("only superuser may alter the SUPERUSER attribute", 42501) since the
|
|
48107
|
+
-- owner 'postgres' isn't a true superuser. The role was already created
|
|
48108
|
+
-- NOSUPERUSER NOCREATEDB NOCREATEROLE, so there is nothing to restate.
|
|
48109
|
+
ALTER ROLE "${role}" WITH LOGIN PASSWORD '${password}';
|
|
47546
48110
|
END IF;
|
|
47547
48111
|
END $LATTICE$`
|
|
47548
48112
|
);
|
|
@@ -47581,9 +48145,28 @@ async function revokeCell(db, table, pk, column, grantee) {
|
|
|
47581
48145
|
async function revokeMemberRole(db, role) {
|
|
47582
48146
|
assertPg(db);
|
|
47583
48147
|
if (!ROLE_RE.test(role)) throw new Error(`lattice: invalid member role name "${role}"`);
|
|
47584
|
-
|
|
48148
|
+
const exists = await getAsyncOrSync(
|
|
48149
|
+
db.adapter,
|
|
48150
|
+
`SELECT 1 AS x FROM pg_roles WHERE rolname = ?`,
|
|
48151
|
+
[role]
|
|
48152
|
+
);
|
|
48153
|
+
if (!exists) return;
|
|
48154
|
+
for (const stmt of [`REASSIGN OWNED BY "${role}" TO CURRENT_USER`, `DROP OWNED BY "${role}"`]) {
|
|
48155
|
+
try {
|
|
48156
|
+
await runAsyncOrSync(db.adapter, stmt);
|
|
48157
|
+
} catch (e6) {
|
|
48158
|
+
if (!isInsufficientPrivilege(e6)) throw e6;
|
|
48159
|
+
console.warn(
|
|
48160
|
+
`[cloud] "${stmt.split(" ").slice(0, 2).join(" ")} \u2026" skipped (insufficient privilege; a scoped member owns no objects): ${e6.message}`
|
|
48161
|
+
);
|
|
48162
|
+
}
|
|
48163
|
+
}
|
|
47585
48164
|
await runAsyncOrSync(db.adapter, `DROP ROLE IF EXISTS "${role}"`);
|
|
47586
48165
|
}
|
|
48166
|
+
function isInsufficientPrivilege(e6) {
|
|
48167
|
+
const err = e6 ?? {};
|
|
48168
|
+
return err.code === "42501" || /permission denied/i.test(err.message ?? "");
|
|
48169
|
+
}
|
|
47587
48170
|
|
|
47588
48171
|
// src/cloud/discover.ts
|
|
47589
48172
|
async function discoverCloudTables(db) {
|
|
@@ -47625,12 +48208,17 @@ function isRowAudience(audience) {
|
|
|
47625
48208
|
const a6 = (audience ?? "").trim();
|
|
47626
48209
|
return a6 === "" || a6 === "everyone" || a6 === "row-audience";
|
|
47627
48210
|
}
|
|
47628
|
-
function audiencePredicate(audience) {
|
|
48211
|
+
function audiencePredicate(audience, ctx) {
|
|
47629
48212
|
if (isRowAudience(audience)) return "true";
|
|
47630
48213
|
const clauses = audience.split("+").map((c6) => c6.trim()).filter(Boolean);
|
|
47631
48214
|
const parts = [];
|
|
47632
48215
|
for (const clause of clauses) {
|
|
47633
48216
|
if (clause === "everyone" || clause === "row-audience") return "true";
|
|
48217
|
+
if (clause === "owner") {
|
|
48218
|
+
if (!ctx) throw new Error('lattice: the "owner" audience needs a row context');
|
|
48219
|
+
parts.push(`lattice_is_owner(${ctx.tableLit}, ${ctx.pkExpr})`);
|
|
48220
|
+
continue;
|
|
48221
|
+
}
|
|
47634
48222
|
const idx = clause.indexOf(":");
|
|
47635
48223
|
const kind = idx === -1 ? clause : clause.slice(0, idx);
|
|
47636
48224
|
const arg = idx === -1 ? "" : clause.slice(idx + 1).trim();
|
|
@@ -47665,7 +48253,7 @@ function audienceViewSql(table, columns, pkCols, columnAudience) {
|
|
|
47665
48253
|
const selectCols = columns.map((col) => {
|
|
47666
48254
|
const aud = columnAudience[col] ?? "";
|
|
47667
48255
|
if (isRowAudience(aud)) return quoteIdent(col);
|
|
47668
|
-
const pred = audiencePredicate(aud);
|
|
48256
|
+
const pred = audiencePredicate(aud, { tableLit: lit, pkExpr });
|
|
47669
48257
|
if (pred === "true") return quoteIdent(col);
|
|
47670
48258
|
const colLit = `'${col.replace(/'/g, "''")}'`;
|
|
47671
48259
|
const full = `(${pred}) OR lattice_cell_visible(${lit}, ${pkExpr}, ${colLit})`;
|
|
@@ -47700,6 +48288,92 @@ async function enableAudienceView(db, table, columns, pkCols, columnAudience) {
|
|
|
47700
48288
|
};
|
|
47701
48289
|
await db.migrate([migration]);
|
|
47702
48290
|
}
|
|
48291
|
+
async function loadColumnPolicy(db, table) {
|
|
48292
|
+
if (db.getDialect() !== "postgres") return {};
|
|
48293
|
+
const rows = await allAsyncOrSync(
|
|
48294
|
+
db.adapter,
|
|
48295
|
+
`SELECT "column_name", "audience" FROM "__lattice_column_policy" WHERE "table_name" = ?`,
|
|
48296
|
+
[table]
|
|
48297
|
+
);
|
|
48298
|
+
const out = {};
|
|
48299
|
+
for (const r6 of rows) out[r6.column_name] = r6.audience;
|
|
48300
|
+
return out;
|
|
48301
|
+
}
|
|
48302
|
+
async function seedColumnPolicyFromYaml(db, table, yamlAudience) {
|
|
48303
|
+
if (db.getDialect() !== "postgres") return;
|
|
48304
|
+
const marker = `internal:cloud-column-seed:${table}:v1`;
|
|
48305
|
+
const already = await getAsyncOrSync(
|
|
48306
|
+
db.adapter,
|
|
48307
|
+
`SELECT 1 AS one FROM "__lattice_migrations" WHERE "version" = ?`,
|
|
48308
|
+
[marker]
|
|
48309
|
+
);
|
|
48310
|
+
if (already) return;
|
|
48311
|
+
for (const [col, aud] of Object.entries(yamlAudience)) {
|
|
48312
|
+
if (isRowAudience(aud)) continue;
|
|
48313
|
+
await runAsyncOrSync(
|
|
48314
|
+
db.adapter,
|
|
48315
|
+
`INSERT INTO "__lattice_column_policy" ("table_name","column_name","audience")
|
|
48316
|
+
VALUES (?, ?, ?) ON CONFLICT ("table_name","column_name") DO NOTHING`,
|
|
48317
|
+
[table, col, aud]
|
|
48318
|
+
);
|
|
48319
|
+
}
|
|
48320
|
+
await runAsyncOrSync(
|
|
48321
|
+
db.adapter,
|
|
48322
|
+
`INSERT INTO "__lattice_migrations" ("version","applied_at") VALUES (?, ?)
|
|
48323
|
+
ON CONFLICT ("version") DO NOTHING`,
|
|
48324
|
+
[marker, (/* @__PURE__ */ new Date()).toISOString()]
|
|
48325
|
+
);
|
|
48326
|
+
}
|
|
48327
|
+
async function regenerateAudienceViewFromDb(db, table, columns, pkCols) {
|
|
48328
|
+
if (db.getDialect() !== "postgres") return;
|
|
48329
|
+
if (pkCols.length === 0) return;
|
|
48330
|
+
const spec = await loadColumnPolicy(db, table);
|
|
48331
|
+
const view = quoteIdent(`${table}_v`);
|
|
48332
|
+
const base = quoteIdent(table);
|
|
48333
|
+
if (!tableNeedsAudienceView(spec)) {
|
|
48334
|
+
await runAsyncOrSync(
|
|
48335
|
+
db.adapter,
|
|
48336
|
+
`DROP VIEW IF EXISTS ${view};
|
|
48337
|
+
GRANT SELECT ON ${base} TO ${MEMBER_GROUP};`
|
|
48338
|
+
);
|
|
48339
|
+
return;
|
|
48340
|
+
}
|
|
48341
|
+
await runAsyncOrSync(db.adapter, audienceViewSql(table, columns, pkCols, spec));
|
|
48342
|
+
}
|
|
48343
|
+
async function setColumnAudience(db, table, column, audience, columns, pkCols) {
|
|
48344
|
+
if (db.getDialect() !== "postgres") return;
|
|
48345
|
+
await runAsyncOrSync(db.adapter, `SELECT lattice_set_column_audience(?, ?, ?)`, [
|
|
48346
|
+
table,
|
|
48347
|
+
column,
|
|
48348
|
+
audience
|
|
48349
|
+
]);
|
|
48350
|
+
await regenerateAudienceViewFromDb(db, table, columns, pkCols);
|
|
48351
|
+
}
|
|
48352
|
+
|
|
48353
|
+
// src/cloud/table-policy.ts
|
|
48354
|
+
async function getTablePolicy(db, table) {
|
|
48355
|
+
if (db.getDialect() !== "postgres") return { defaultRowVisibility: "private", neverShare: false };
|
|
48356
|
+
const row = await getAsyncOrSync(
|
|
48357
|
+
db.adapter,
|
|
48358
|
+
`SELECT "default_row_visibility", "never_share" FROM "__lattice_table_policy" WHERE "table_name" = ?`,
|
|
48359
|
+
[table]
|
|
48360
|
+
);
|
|
48361
|
+
return {
|
|
48362
|
+
defaultRowVisibility: row?.default_row_visibility === "everyone" ? "everyone" : "private",
|
|
48363
|
+
neverShare: row?.never_share === true
|
|
48364
|
+
};
|
|
48365
|
+
}
|
|
48366
|
+
async function setTableDefaultVisibility(db, table, visibility) {
|
|
48367
|
+
if (db.getDialect() !== "postgres") return;
|
|
48368
|
+
await runAsyncOrSync(db.adapter, `SELECT lattice_set_table_default_visibility(?, ?)`, [
|
|
48369
|
+
table,
|
|
48370
|
+
visibility
|
|
48371
|
+
]);
|
|
48372
|
+
}
|
|
48373
|
+
async function setTableNeverShare(db, table, on) {
|
|
48374
|
+
if (db.getDialect() !== "postgres") return;
|
|
48375
|
+
await runAsyncOrSync(db.adapter, `SELECT lattice_set_table_never_share(?, ?)`, [table, on]);
|
|
48376
|
+
}
|
|
47703
48377
|
|
|
47704
48378
|
// src/cloud/fold-cache.ts
|
|
47705
48379
|
function viewerSignature(viewer) {
|
|
@@ -47736,6 +48410,7 @@ var FoldCache = class {
|
|
|
47736
48410
|
};
|
|
47737
48411
|
|
|
47738
48412
|
// src/cloud/settings.ts
|
|
48413
|
+
var import_node_crypto14 = require("crypto");
|
|
47739
48414
|
var CLOUD_SETTING_SYSTEM_PROMPT = "chat_system_prompt";
|
|
47740
48415
|
var CLOUD_SETTINGS_BOOTSTRAP_SQL = `
|
|
47741
48416
|
-- Owner-controlled, cloud-wide key/value settings. No grant to the member group,
|
|
@@ -47774,11 +48449,8 @@ END $fn$;
|
|
|
47774
48449
|
`;
|
|
47775
48450
|
async function installCloudSettings(db) {
|
|
47776
48451
|
if (db.getDialect() !== "postgres") return;
|
|
47777
|
-
const
|
|
47778
|
-
|
|
47779
|
-
sql: CLOUD_SETTINGS_BOOTSTRAP_SQL
|
|
47780
|
-
};
|
|
47781
|
-
await db.migrate([migration]);
|
|
48452
|
+
const schema = await cloudSchema(db);
|
|
48453
|
+
await runCloudBootstrapSql(db, pinDefinerSearchPath(CLOUD_SETTINGS_BOOTSTRAP_SQL, schema));
|
|
47782
48454
|
}
|
|
47783
48455
|
async function getCloudSetting(db, key) {
|
|
47784
48456
|
if (db.getDialect() !== "postgres") return null;
|
|
@@ -47797,23 +48469,39 @@ async function setCloudSetting(db, key, value) {
|
|
|
47797
48469
|
}
|
|
47798
48470
|
|
|
47799
48471
|
// src/cloud/setup.ts
|
|
48472
|
+
async function secureNewCloudTable(db, table, pk) {
|
|
48473
|
+
if (db.getDialect() !== "postgres") return;
|
|
48474
|
+
if (table.startsWith("__lattice_") || table.startsWith("_lattice_")) return;
|
|
48475
|
+
if (pk.length === 0) return;
|
|
48476
|
+
await backfillOwnership(db, table, pk);
|
|
48477
|
+
await enableRlsForTable(db, table, pk);
|
|
48478
|
+
const cols = db.getRegisteredColumns(table);
|
|
48479
|
+
if (cols) {
|
|
48480
|
+
await seedColumnPolicyFromYaml(db, table, db.getColumnAudience(table));
|
|
48481
|
+
await regenerateAudienceViewFromDb(db, table, Object.keys(cols), pk);
|
|
48482
|
+
}
|
|
48483
|
+
}
|
|
47800
48484
|
async function secureCloud(db) {
|
|
47801
48485
|
if (db.getDialect() !== "postgres") return;
|
|
47802
48486
|
await installCloudRls(db);
|
|
47803
48487
|
await installCloudSettings(db);
|
|
47804
48488
|
await db.ensureObservationSubstrate();
|
|
47805
48489
|
await enableChangelogRls(db);
|
|
47806
|
-
|
|
47807
|
-
|
|
47808
|
-
|
|
47809
|
-
|
|
47810
|
-
|
|
47811
|
-
await
|
|
47812
|
-
const cols = db.getRegisteredColumns(table);
|
|
47813
|
-
if (cols) {
|
|
47814
|
-
await enableAudienceView(db, table, Object.keys(cols), pk, db.getColumnAudience(table));
|
|
47815
|
-
}
|
|
48490
|
+
const registered = db.getRegisteredTableNames();
|
|
48491
|
+
for (const table of registered) {
|
|
48492
|
+
await secureNewCloudTable(db, table, db.getPrimaryKey(table));
|
|
48493
|
+
}
|
|
48494
|
+
if (registered.includes("secrets")) {
|
|
48495
|
+
await runAsyncOrSync(db.adapter, `SELECT lattice_set_table_never_share('secrets', true)`);
|
|
47816
48496
|
}
|
|
48497
|
+
await runAsyncOrSync(
|
|
48498
|
+
db.adapter,
|
|
48499
|
+
`DO $LATTICE$ BEGIN
|
|
48500
|
+
IF to_regclass('__lattice_user_identity') IS NOT NULL THEN
|
|
48501
|
+
EXECUTE 'GRANT SELECT, INSERT, UPDATE ON "__lattice_user_identity" TO ${MEMBER_GROUP}';
|
|
48502
|
+
END IF;
|
|
48503
|
+
END $LATTICE$`
|
|
48504
|
+
);
|
|
47817
48505
|
}
|
|
47818
48506
|
|
|
47819
48507
|
// src/ai/llm-client.ts
|
|
@@ -48410,6 +49098,7 @@ function defaultPdfSender(auth) {
|
|
|
48410
49098
|
NATIVE_ENTITY_NAMES,
|
|
48411
49099
|
NATIVE_REGISTRY_TABLE,
|
|
48412
49100
|
PostgresAdapter,
|
|
49101
|
+
ProgressThrottle,
|
|
48413
49102
|
READ_ONLY_HEADER,
|
|
48414
49103
|
ROOT_DIRNAME,
|
|
48415
49104
|
ReferenceUnavailableError,
|
|
@@ -48473,6 +49162,7 @@ function defaultPdfSender(auth) {
|
|
|
48473
49162
|
getCloudSetting,
|
|
48474
49163
|
getDbCredential,
|
|
48475
49164
|
getOrCreateMasterKey,
|
|
49165
|
+
getTablePolicy,
|
|
48476
49166
|
getWorkspace,
|
|
48477
49167
|
grantCell,
|
|
48478
49168
|
hasFtsIndex,
|
|
@@ -48490,6 +49180,7 @@ function defaultPdfSender(auth) {
|
|
|
48490
49180
|
listNativeBindings,
|
|
48491
49181
|
listTokens,
|
|
48492
49182
|
listWorkspaces,
|
|
49183
|
+
loadColumnPolicy,
|
|
48493
49184
|
manifestPath,
|
|
48494
49185
|
markdownTable,
|
|
48495
49186
|
memberRoleName,
|
|
@@ -48517,6 +49208,7 @@ function defaultPdfSender(auth) {
|
|
|
48517
49208
|
readToken,
|
|
48518
49209
|
referenceLocalFile,
|
|
48519
49210
|
referenceUrl,
|
|
49211
|
+
regenerateAudienceViewFromDb,
|
|
48520
49212
|
registerNativeEntities,
|
|
48521
49213
|
registryPath,
|
|
48522
49214
|
resolveActiveS3Config,
|
|
@@ -48531,9 +49223,13 @@ function defaultPdfSender(auth) {
|
|
|
48531
49223
|
saveDbCredentialForTeam,
|
|
48532
49224
|
sealUnderSource,
|
|
48533
49225
|
secureCloud,
|
|
49226
|
+
seedColumnPolicyFromYaml,
|
|
48534
49227
|
setActiveWorkspace,
|
|
48535
49228
|
setCloudSetting,
|
|
49229
|
+
setColumnAudience,
|
|
48536
49230
|
setRowVisibility,
|
|
49231
|
+
setTableDefaultVisibility,
|
|
49232
|
+
setTableNeverShare,
|
|
48537
49233
|
shredSource,
|
|
48538
49234
|
slugify,
|
|
48539
49235
|
summarizeText,
|