Initial commit
This commit is contained in:
commit
4d62c45b8d
30 changed files with 6497 additions and 0 deletions
44
.gitattributes
vendored
Normal file
44
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# Default line-ending handling for text files.
|
||||
* text=auto
|
||||
|
||||
# Git LFS. Run `git lfs install` once per clone to activate the filters below.
|
||||
# Nothing in the project currently requires LFS, but these patterns catch
|
||||
# common binary blobs so anyone dropping one into the tree gets it tracked
|
||||
# correctly without remembering to update this file.
|
||||
|
||||
# Archives
|
||||
*.zip filter=lfs diff=lfs merge=lfs -text
|
||||
*.tar filter=lfs diff=lfs merge=lfs -text
|
||||
*.tar.gz filter=lfs diff=lfs merge=lfs -text
|
||||
*.tgz filter=lfs diff=lfs merge=lfs -text
|
||||
*.gz filter=lfs diff=lfs merge=lfs -text
|
||||
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
||||
*.7z filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
# Databases / snapshots
|
||||
*.sqlite filter=lfs diff=lfs merge=lfs -text
|
||||
*.sqlite3 filter=lfs diff=lfs merge=lfs -text
|
||||
*.db filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
# Images
|
||||
*.png filter=lfs diff=lfs merge=lfs -text
|
||||
*.jpg filter=lfs diff=lfs merge=lfs -text
|
||||
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
||||
*.gif filter=lfs diff=lfs merge=lfs -text
|
||||
*.webp filter=lfs diff=lfs merge=lfs -text
|
||||
*.ico filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
# Documents
|
||||
*.pdf filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
# Fonts
|
||||
*.ttf filter=lfs diff=lfs merge=lfs -text
|
||||
*.otf filter=lfs diff=lfs merge=lfs -text
|
||||
*.woff filter=lfs diff=lfs merge=lfs -text
|
||||
*.woff2 filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
# Audio / video
|
||||
*.mp3 filter=lfs diff=lfs merge=lfs -text
|
||||
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
||||
*.wav filter=lfs diff=lfs merge=lfs -text
|
||||
*.webm filter=lfs diff=lfs merge=lfs -text
|
||||
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Upstream Saltcorn checkout. It has its own .git and remote; manage it via
|
||||
# `git -C saltcorn ...` or re-clone via ./installSaltcorn.sh.
|
||||
/saltcorn/
|
||||
|
||||
# Per-instance runtime state. Contains SQLite DB, uploaded files, and an
|
||||
# env.sh with a generated session secret -- none of which should be shared.
|
||||
# Recreate via installSaltcorn.sh.
|
||||
/.dev-state/
|
||||
/.dev-state-test/
|
||||
|
||||
# npm output (in case dev-deploy or future siblings ever take deps).
|
||||
node_modules/
|
||||
|
||||
# Editor / OS junk.
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.idea/
|
||||
.vscode/
|
||||
62
dev-deploy/index.js
Normal file
62
dev-deploy/index.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
// dev-deploy: Saltcorn plugin for migrating metadata changes across
|
||||
// Dev/Test/Prod environments via an ops journal with stable UUIDs.
|
||||
|
||||
const { PLUGIN_NAME, PLUGIN_VERSION } = require("./lib/constants");
|
||||
const { createAllTables } = require("./lib/schema");
|
||||
const { getEnv, initEnvIfMissing, markBootstrapped } = require("./lib/env");
|
||||
const { backfillAll } = require("./lib/entityIds");
|
||||
const { installAllWraps } = require("./lib/wrap");
|
||||
const { routes } = require("./lib/routes");
|
||||
|
||||
|
||||
const log = (msg) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[${PLUGIN_NAME}] ${msg}`);
|
||||
};
|
||||
|
||||
|
||||
const ensureCsrfBypass = async () => {
|
||||
try {
|
||||
const { getState } = require("@saltcorn/data/db/state");
|
||||
const current = getState().getConfig("disable_csrf_routes", "");
|
||||
const want = "/dev-deploy/api/";
|
||||
const entries = current.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
if (!entries.includes(want)) {
|
||||
entries.push(want);
|
||||
await getState().setConfig("disable_csrf_routes", entries.join(","));
|
||||
}
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[${PLUGIN_NAME}] failed to register csrf bypass:`, e);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const onLoad = async (cfg) => {
|
||||
try {
|
||||
await createAllTables();
|
||||
const env = await initEnvIfMissing();
|
||||
if (!env.bootstrapped_at) {
|
||||
const counts = await backfillAll();
|
||||
const total = Object.values(counts).reduce((a, b) => a + b, 0);
|
||||
await markBootstrapped(env.env_id);
|
||||
log(`v${PLUGIN_VERSION} bootstrapped env_id=${env.env_id} backfilled ${total} entities ${JSON.stringify(counts)}`);
|
||||
} else {
|
||||
log(`v${PLUGIN_VERSION} loaded env_id=${env.env_id}`);
|
||||
}
|
||||
installAllWraps();
|
||||
await ensureCsrfBypass();
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[${PLUGIN_NAME}] onLoad failed:`, err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
module.exports = {
|
||||
sc_plugin_api_version: 1,
|
||||
plugin_name: PLUGIN_NAME,
|
||||
onLoad: onLoad,
|
||||
routes: routes
|
||||
};
|
||||
1135
dev-deploy/lib/apply.js
Normal file
1135
dev-deploy/lib/apply.js
Normal file
File diff suppressed because it is too large
Load diff
65
dev-deploy/lib/constants.js
Normal file
65
dev-deploy/lib/constants.js
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
// Compile-time constants for the dev-deploy plugin.
|
||||
|
||||
const PLUGIN_NAME = "dev-deploy";
|
||||
const PLUGIN_VERSION = "0.0.1";
|
||||
|
||||
// Namespace UUID for deterministic IDs derived from (kind, name).
|
||||
// Generated once via crypto.randomUUID() and frozen here forever.
|
||||
// Two environments that bootstrap from the same metadata population
|
||||
// will assign identical UUIDs to entities with the same (kind, name).
|
||||
const ID_NAMESPACE = "8b3a1e0d-4f6c-4d2a-9c5b-7e8f9a0b1c2d";
|
||||
|
||||
const OP_SCHEMA_VERSION = 1;
|
||||
|
||||
const DATA_MODES = {
|
||||
MANAGED: "managed",
|
||||
STARTER: "starter",
|
||||
USER: "user"
|
||||
};
|
||||
|
||||
const DESTRUCTIVE_POLICY = {
|
||||
AUTO: "auto",
|
||||
CONFIRM: "confirm",
|
||||
REFUSE: "refuse"
|
||||
};
|
||||
|
||||
// Entity kinds the plugin tracks. Children point at parents via parent_uuid.
|
||||
const ENTITY_KINDS = {
|
||||
TABLE: "table",
|
||||
FIELD: "field",
|
||||
VIEW: "view",
|
||||
PAGE: "page",
|
||||
TRIGGER: "trigger",
|
||||
ROLE: "role",
|
||||
LIBRARY: "library",
|
||||
TAG: "tag",
|
||||
CONSTRAINT: "constraint",
|
||||
FILE: "file",
|
||||
PAGE_GROUP: "page_group",
|
||||
PAGE_GROUP_MEMBER: "page_group_member",
|
||||
WORKFLOW_STEP: "workflow_step"
|
||||
};
|
||||
|
||||
|
||||
// Files don't have a meaningful integer id in current Saltcorn (File.create
|
||||
// doesn't insert into _sc_files anymore — files are identified by their disk
|
||||
// location and metadata via xattrs). We derive a stable 31-bit int from the
|
||||
// location string so files fit the existing UNIQUE(kind, current_id) shape of
|
||||
// _dd_entity_ids without a schema change.
|
||||
const fileLocationToId = (location) => {
|
||||
const crypto = require("crypto");
|
||||
const hash = crypto.createHash("sha256").update(String(location)).digest();
|
||||
return hash.readUInt32BE(0) & 0x7FFFFFFF;
|
||||
};
|
||||
|
||||
|
||||
module.exports = {
|
||||
PLUGIN_NAME,
|
||||
PLUGIN_VERSION,
|
||||
ID_NAMESPACE,
|
||||
OP_SCHEMA_VERSION,
|
||||
DATA_MODES,
|
||||
DESTRUCTIVE_POLICY,
|
||||
ENTITY_KINDS,
|
||||
fileLocationToId
|
||||
};
|
||||
65
dev-deploy/lib/context.js
Normal file
65
dev-deploy/lib/context.js
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
// Per-async-flow context for the mutation wraps.
|
||||
//
|
||||
// AsyncLocalStorage gives every wrap a stack of in-flight op_ids so that ops
|
||||
// triggered while another op is running (e.g. cascading field deletes inside
|
||||
// a table delete) can attach themselves to the outer op as parent_op_id, and
|
||||
// share its correlation_id.
|
||||
|
||||
const { AsyncLocalStorage } = require("async_hooks");
|
||||
|
||||
const { randomUuid } = require("./ids");
|
||||
|
||||
|
||||
const storage = new AsyncLocalStorage();
|
||||
|
||||
|
||||
const enterOp = async (opId, fn) => {
|
||||
const current = storage.getStore();
|
||||
const correlationId = current ? current.correlationId : randomUuid();
|
||||
const parentStack = current ? current.stack : [];
|
||||
const newStore = {
|
||||
stack: [...parentStack, opId],
|
||||
correlationId: correlationId
|
||||
};
|
||||
return await storage.run(newStore, fn);
|
||||
};
|
||||
|
||||
|
||||
const currentParentOpId = () => {
|
||||
const store = storage.getStore();
|
||||
if (!store || store.stack.length < 2) {
|
||||
return null;
|
||||
}
|
||||
return store.stack[store.stack.length - 2];
|
||||
};
|
||||
|
||||
|
||||
const currentCorrelationId = () => {
|
||||
const store = storage.getStore();
|
||||
return store ? store.correlationId : null;
|
||||
};
|
||||
|
||||
|
||||
// Suppression mode: when applying ingested ops we run the underlying Saltcorn
|
||||
// model methods but want the CRUD wraps to pass through silently — no journal
|
||||
// writes, no entity_id assignment. The apply handler manages those side effects
|
||||
// itself with the source-env's UUID.
|
||||
const runSuppressed = async (fn) => {
|
||||
const current = storage.getStore() || { stack: [], correlationId: null };
|
||||
return await storage.run({ ...current, suppressed: true }, fn);
|
||||
};
|
||||
|
||||
|
||||
const isSuppressed = () => {
|
||||
const s = storage.getStore();
|
||||
return !!(s && s.suppressed);
|
||||
};
|
||||
|
||||
|
||||
module.exports = {
|
||||
enterOp,
|
||||
currentParentOpId,
|
||||
currentCorrelationId,
|
||||
runSuppressed,
|
||||
isSuppressed
|
||||
};
|
||||
150
dev-deploy/lib/crypto.js
Normal file
150
dev-deploy/lib/crypto.js
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
// Crypto primitives for dev-deploy peer auth.
|
||||
//
|
||||
// seal/open — AES-256-GCM for at-rest encryption of peer secrets.
|
||||
// The 32-byte KEK is derived once per process via
|
||||
// HKDF-SHA256 from SALTCORN_SESSION_SECRET.
|
||||
// sign/verify — HMAC-SHA256 for signed peer-to-peer requests.
|
||||
// buildCanonical — canonical string format every signed request agrees on.
|
||||
// randomSecret — 32 random bytes (peer_secret) at pairing time.
|
||||
//
|
||||
// Rotating SALTCORN_SESSION_SECRET invalidates all peer pairings (the KEK
|
||||
// changes, so existing ciphertexts no longer decrypt). Documented behavior.
|
||||
|
||||
const crypto = require("crypto");
|
||||
|
||||
|
||||
const KEK_INFO = "dev-deploy:peer-secrets:aes-gcm-key:v1";
|
||||
const HMAC_ALGORITHM = "sha256";
|
||||
const GCM_ALGORITHM = "aes-256-gcm";
|
||||
const IV_BYTES = 12;
|
||||
const TAG_BYTES = 16;
|
||||
const SECRET_BYTES = 32;
|
||||
const NONCE_BYTES = 16;
|
||||
const SKEW_TOLERANCE_MS = 5 * 60 * 1000;
|
||||
|
||||
|
||||
let cachedKek = null;
|
||||
|
||||
|
||||
const getSessionSecret = () => {
|
||||
const fromEnv = process.env.SALTCORN_SESSION_SECRET;
|
||||
if (fromEnv && fromEnv.length > 0) {
|
||||
return fromEnv;
|
||||
}
|
||||
// Fallback to Saltcorn state config if available
|
||||
try {
|
||||
const { getState } = require("@saltcorn/data/db/state");
|
||||
const v = getState().getConfig("session_secret");
|
||||
if (v) return v;
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
throw new Error("dev-deploy: SALTCORN_SESSION_SECRET not available; cannot derive KEK");
|
||||
};
|
||||
|
||||
|
||||
const getKek = () => {
|
||||
if (cachedKek) {
|
||||
return cachedKek;
|
||||
}
|
||||
const sessionSecret = getSessionSecret();
|
||||
const salt = Buffer.from(KEK_INFO, "utf8");
|
||||
const ikm = Buffer.from(sessionSecret, "utf8");
|
||||
cachedKek = crypto.hkdfSync(HMAC_ALGORITHM, ikm, salt, Buffer.from(KEK_INFO, "utf8"), 32);
|
||||
return cachedKek;
|
||||
};
|
||||
|
||||
|
||||
const seal = (plaintext) => {
|
||||
const iv = crypto.randomBytes(IV_BYTES);
|
||||
const cipher = crypto.createCipheriv(GCM_ALGORITHM, getKek(), iv);
|
||||
const buf = Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(plaintext);
|
||||
const ct = Buffer.concat([cipher.update(buf), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return { ciphertext: ct, iv: iv, tag: tag };
|
||||
};
|
||||
|
||||
|
||||
const open = (sealed) => {
|
||||
const decipher = crypto.createDecipheriv(GCM_ALGORITHM, getKek(), sealed.iv);
|
||||
decipher.setAuthTag(sealed.tag);
|
||||
return Buffer.concat([decipher.update(sealed.ciphertext), decipher.final()]);
|
||||
};
|
||||
|
||||
|
||||
const randomSecret = () => {
|
||||
return crypto.randomBytes(SECRET_BYTES);
|
||||
};
|
||||
|
||||
|
||||
const randomNonce = () => {
|
||||
return crypto.randomBytes(NONCE_BYTES);
|
||||
};
|
||||
|
||||
|
||||
const sha256Hex = (data) => {
|
||||
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data || "");
|
||||
return crypto.createHash("sha256").update(buf).digest("hex");
|
||||
};
|
||||
|
||||
|
||||
const buildCanonical = ({ timestamp, nonce, method, path, body }) => {
|
||||
const bodyHash = sha256Hex(body || "");
|
||||
return [
|
||||
String(timestamp),
|
||||
String(nonce),
|
||||
String(method).toUpperCase(),
|
||||
String(path),
|
||||
bodyHash
|
||||
].join("\n");
|
||||
};
|
||||
|
||||
|
||||
const sign = (secret, canonical) => {
|
||||
const mac = crypto.createHmac(HMAC_ALGORITHM, secret);
|
||||
mac.update(canonical, "utf8");
|
||||
return mac.digest("hex");
|
||||
};
|
||||
|
||||
|
||||
const verifySignature = (secret, canonical, providedHex) => {
|
||||
if (!providedHex || typeof providedHex !== "string") {
|
||||
return false;
|
||||
}
|
||||
let expectedBuf;
|
||||
let providedBuf;
|
||||
try {
|
||||
expectedBuf = Buffer.from(sign(secret, canonical), "hex");
|
||||
providedBuf = Buffer.from(providedHex, "hex");
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
if (expectedBuf.length !== providedBuf.length) {
|
||||
return false;
|
||||
}
|
||||
return crypto.timingSafeEqual(expectedBuf, providedBuf);
|
||||
};
|
||||
|
||||
|
||||
const timestampWithinSkew = (tsString) => {
|
||||
const ts = Number(tsString);
|
||||
if (!Number.isFinite(ts)) {
|
||||
return false;
|
||||
}
|
||||
const now = Date.now();
|
||||
return Math.abs(now - ts) <= SKEW_TOLERANCE_MS;
|
||||
};
|
||||
|
||||
|
||||
module.exports = {
|
||||
seal,
|
||||
open,
|
||||
randomSecret,
|
||||
randomNonce,
|
||||
buildCanonical,
|
||||
sign,
|
||||
verifySignature,
|
||||
timestampWithinSkew,
|
||||
sha256Hex,
|
||||
SKEW_TOLERANCE_MS
|
||||
};
|
||||
349
dev-deploy/lib/entityIds.js
Normal file
349
dev-deploy/lib/entityIds.js
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
// Stable UUIDs for Saltcorn metadata entities.
|
||||
//
|
||||
// Saltcorn core identifies entities by integer id and human name. dev-deploy
|
||||
// needs a stable identity that survives across environments and renames, so we
|
||||
// maintain a side-table _dd_entity_ids mapping (kind, current_id) -> uuid.
|
||||
//
|
||||
// First-run "backfill" assigns deterministic UUIDs (hash of namespace + kind
|
||||
// + canonical name) so two environments installed from the same pack converge
|
||||
// on the same UUIDs without a coordination step.
|
||||
|
||||
const db = require("@saltcorn/data/db");
|
||||
|
||||
const Table = require("@saltcorn/data/models/table");
|
||||
const Field = require("@saltcorn/data/models/field");
|
||||
const View = require("@saltcorn/data/models/view");
|
||||
const Page = require("@saltcorn/data/models/page");
|
||||
const Trigger = require("@saltcorn/data/models/trigger");
|
||||
const Role = require("@saltcorn/data/models/role");
|
||||
const Library = require("@saltcorn/data/models/library");
|
||||
const Tag = require("@saltcorn/data/models/tag");
|
||||
const TableConstraint = require("@saltcorn/data/models/table_constraints");
|
||||
const PageGroup = require("@saltcorn/data/models/page_group");
|
||||
const WorkflowStep = require("@saltcorn/data/models/workflow_step");
|
||||
|
||||
const { deterministicUuid, randomUuid } = require("./ids");
|
||||
const { ENTITY_KINDS } = require("./constants");
|
||||
|
||||
|
||||
const assignNewUuid = async (kind, currentId, currentName, parentUuid) => {
|
||||
const row = {
|
||||
uuid: randomUuid(),
|
||||
kind: kind,
|
||||
current_name: currentName,
|
||||
current_id: currentId,
|
||||
parent_uuid: parentUuid || null,
|
||||
created_at: new Date().toISOString()
|
||||
};
|
||||
await db.insert("_dd_entity_ids", row, { noid: true });
|
||||
return row.uuid;
|
||||
};
|
||||
|
||||
|
||||
const canonicalKey = (kind, entity, parentName) => {
|
||||
if (kind === ENTITY_KINDS.FIELD) {
|
||||
return `${parentName || "?"}.${entity.name}`;
|
||||
}
|
||||
return entity.name;
|
||||
};
|
||||
|
||||
|
||||
// Stable fingerprint for a constraint, scoped to its parent table's UUID so
|
||||
// it's globally unique across the journal. Two instances installed from the
|
||||
// same base agree on this fingerprint and so derive identical UUIDs.
|
||||
const constraintFingerprint = (con, parentUuid) => {
|
||||
const cfg = con.configuration || {};
|
||||
let detail;
|
||||
if (con.type === "Unique" && cfg.fields) {
|
||||
detail = [...cfg.fields].sort().join(",");
|
||||
} else if (con.type === "Index") {
|
||||
detail = cfg.field || "";
|
||||
} else if (con.type === "Formula") {
|
||||
detail = cfg.formula || "";
|
||||
} else {
|
||||
detail = JSON.stringify(cfg);
|
||||
}
|
||||
return `${parentUuid || "?"}|${con.type}|${detail}`;
|
||||
};
|
||||
|
||||
|
||||
// Friendly display name for _dd_entity_ids.current_name. Not used for identity.
|
||||
const constraintDisplayName = (con) => {
|
||||
const cfg = con.configuration || {};
|
||||
if (con.type === "Unique") return `unique(${(cfg.fields || []).join(",")})`;
|
||||
if (con.type === "Index") return `index(${cfg.field || ""})`;
|
||||
if (con.type === "Formula") return `formula`;
|
||||
return `${con.type}_${con.id || "new"}`;
|
||||
};
|
||||
|
||||
|
||||
const ensureUuid = async (kind, currentId, currentName, parentUuid, canonical) => {
|
||||
const existing = await db.selectMaybeOne("_dd_entity_ids", { kind: kind, current_id: currentId });
|
||||
if (existing) {
|
||||
return existing.uuid;
|
||||
}
|
||||
const uuid = deterministicUuid(kind, canonical || currentName);
|
||||
const row = {
|
||||
uuid: uuid,
|
||||
kind: kind,
|
||||
current_name: currentName,
|
||||
current_id: currentId,
|
||||
parent_uuid: parentUuid || null,
|
||||
created_at: new Date().toISOString()
|
||||
};
|
||||
await db.insert("_dd_entity_ids", row, { noid: true });
|
||||
return uuid;
|
||||
};
|
||||
|
||||
|
||||
const lookupByCurrent = async (kind, currentId) => {
|
||||
return await db.selectMaybeOne("_dd_entity_ids", { kind: kind, current_id: currentId });
|
||||
};
|
||||
|
||||
|
||||
const lookupByUuid = async (uuid) => {
|
||||
return await db.selectMaybeOne("_dd_entity_ids", { uuid: uuid });
|
||||
};
|
||||
|
||||
|
||||
// Insert a row with a specific UUID (used by apply.js when ingesting an op
|
||||
// that created an entity on a peer — we want to preserve the peer's UUID).
|
||||
const adoptUuid = async (uuid, kind, currentId, currentName, parentUuid) => {
|
||||
const row = {
|
||||
uuid: uuid,
|
||||
kind: kind,
|
||||
current_name: currentName,
|
||||
current_id: currentId,
|
||||
parent_uuid: parentUuid || null,
|
||||
created_at: new Date().toISOString()
|
||||
};
|
||||
await db.insert("_dd_entity_ids", row, { noid: true });
|
||||
return uuid;
|
||||
};
|
||||
|
||||
|
||||
const updateName = async (kind, currentId, newName) => {
|
||||
await db.updateWhere("_dd_entity_ids", { current_name: newName }, { kind: kind, current_id: currentId });
|
||||
};
|
||||
|
||||
|
||||
const removeEntityRow = async (kind, currentId) => {
|
||||
await db.deleteWhere("_dd_entity_ids", { kind: kind, current_id: currentId });
|
||||
};
|
||||
|
||||
|
||||
// Backfill helpers: each returns the count of new UUIDs inserted.
|
||||
|
||||
const backfillTables = async () => {
|
||||
const tables = await Table.find({}, { cached: false });
|
||||
let added = 0;
|
||||
for (const t of tables) {
|
||||
const before = await lookupByCurrent(ENTITY_KINDS.TABLE, t.id);
|
||||
await ensureUuid(ENTITY_KINDS.TABLE, t.id, t.name, null, t.name);
|
||||
if (!before) {
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
return added;
|
||||
};
|
||||
|
||||
|
||||
const backfillFields = async () => {
|
||||
const tables = await Table.find({}, { cached: false });
|
||||
let added = 0;
|
||||
for (const t of tables) {
|
||||
const tableUuidRow = await lookupByCurrent(ENTITY_KINDS.TABLE, t.id);
|
||||
if (!tableUuidRow) {
|
||||
continue;
|
||||
}
|
||||
const fields = t.getFields ? t.getFields() : (await Field.find({ table_id: t.id }));
|
||||
for (const f of fields) {
|
||||
const before = await lookupByCurrent(ENTITY_KINDS.FIELD, f.id);
|
||||
await ensureUuid(ENTITY_KINDS.FIELD, f.id, f.name, tableUuidRow.uuid, `${t.name}.${f.name}`);
|
||||
if (!before) {
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return added;
|
||||
};
|
||||
|
||||
|
||||
const backfillViews = async () => {
|
||||
const views = await View.find({});
|
||||
let added = 0;
|
||||
for (const v of views) {
|
||||
const before = await lookupByCurrent(ENTITY_KINDS.VIEW, v.id);
|
||||
let parentUuid = null;
|
||||
if (v.table_id) {
|
||||
const t = await lookupByCurrent(ENTITY_KINDS.TABLE, v.table_id);
|
||||
parentUuid = t ? t.uuid : null;
|
||||
}
|
||||
await ensureUuid(ENTITY_KINDS.VIEW, v.id, v.name, parentUuid, v.name);
|
||||
if (!before) {
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
return added;
|
||||
};
|
||||
|
||||
|
||||
const backfillPages = async () => {
|
||||
const pages = await Page.find({});
|
||||
let added = 0;
|
||||
for (const p of pages) {
|
||||
const before = await lookupByCurrent(ENTITY_KINDS.PAGE, p.id);
|
||||
await ensureUuid(ENTITY_KINDS.PAGE, p.id, p.name, null, p.name);
|
||||
if (!before) {
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
return added;
|
||||
};
|
||||
|
||||
|
||||
const backfillTriggers = async () => {
|
||||
const triggers = Trigger.find({});
|
||||
let added = 0;
|
||||
for (const tr of triggers) {
|
||||
const before = await lookupByCurrent(ENTITY_KINDS.TRIGGER, tr.id);
|
||||
let parentUuid = null;
|
||||
if (tr.table_id) {
|
||||
const t = await lookupByCurrent(ENTITY_KINDS.TABLE, tr.table_id);
|
||||
parentUuid = t ? t.uuid : null;
|
||||
}
|
||||
await ensureUuid(ENTITY_KINDS.TRIGGER, tr.id, tr.name || `trigger_${tr.id}`, parentUuid, tr.name || `trigger_${tr.id}`);
|
||||
if (!before) {
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
return added;
|
||||
};
|
||||
|
||||
|
||||
const backfillRoles = async () => {
|
||||
const roles = await Role.find({});
|
||||
let added = 0;
|
||||
for (const r of roles) {
|
||||
const before = await lookupByCurrent(ENTITY_KINDS.ROLE, r.id);
|
||||
await ensureUuid(ENTITY_KINDS.ROLE, r.id, r.role, null, r.role);
|
||||
if (!before) {
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
return added;
|
||||
};
|
||||
|
||||
|
||||
const backfillLibrary = async () => {
|
||||
const items = await Library.find({});
|
||||
let added = 0;
|
||||
for (const li of items) {
|
||||
const before = await lookupByCurrent(ENTITY_KINDS.LIBRARY, li.id);
|
||||
await ensureUuid(ENTITY_KINDS.LIBRARY, li.id, li.name, null, li.name);
|
||||
if (!before) {
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
return added;
|
||||
};
|
||||
|
||||
|
||||
const backfillTags = async () => {
|
||||
const tags = await Tag.find({});
|
||||
let added = 0;
|
||||
for (const tg of tags) {
|
||||
const before = await lookupByCurrent(ENTITY_KINDS.TAG, tg.id);
|
||||
await ensureUuid(ENTITY_KINDS.TAG, tg.id, tg.name, null, tg.name);
|
||||
if (!before) {
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
return added;
|
||||
};
|
||||
|
||||
|
||||
const backfillTableConstraints = async () => {
|
||||
const cons = await TableConstraint.find({});
|
||||
let added = 0;
|
||||
for (const c of cons) {
|
||||
const before = await lookupByCurrent(ENTITY_KINDS.CONSTRAINT, c.id);
|
||||
let parentUuid = null;
|
||||
if (c.table_id) {
|
||||
const t = await lookupByCurrent(ENTITY_KINDS.TABLE, c.table_id);
|
||||
parentUuid = t ? t.uuid : null;
|
||||
}
|
||||
const canonical = constraintFingerprint(c, parentUuid);
|
||||
await ensureUuid(ENTITY_KINDS.CONSTRAINT, c.id, constraintDisplayName(c), parentUuid, canonical);
|
||||
if (!before) {
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
return added;
|
||||
};
|
||||
|
||||
|
||||
const backfillPageGroups = async () => {
|
||||
const groups = await PageGroup.find({});
|
||||
let added = 0;
|
||||
for (const g of groups) {
|
||||
const before = await lookupByCurrent(ENTITY_KINDS.PAGE_GROUP, g.id);
|
||||
await ensureUuid(ENTITY_KINDS.PAGE_GROUP, g.id, g.name, null, g.name);
|
||||
if (!before) {
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
return added;
|
||||
};
|
||||
|
||||
|
||||
const backfillWorkflowSteps = async () => {
|
||||
const steps = await WorkflowStep.find({});
|
||||
let added = 0;
|
||||
for (const s of steps) {
|
||||
const before = await lookupByCurrent(ENTITY_KINDS.WORKFLOW_STEP, s.id);
|
||||
let parentUuid = null;
|
||||
if (s.trigger_id) {
|
||||
const tr = await lookupByCurrent(ENTITY_KINDS.TRIGGER, s.trigger_id);
|
||||
parentUuid = tr ? tr.uuid : null;
|
||||
}
|
||||
const canonical = `${parentUuid || "?"}|${s.name || s.id}`;
|
||||
await ensureUuid(ENTITY_KINDS.WORKFLOW_STEP, s.id, s.name || `step_${s.id}`, parentUuid, canonical);
|
||||
if (!before) {
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
return added;
|
||||
};
|
||||
|
||||
|
||||
const backfillAll = async () => {
|
||||
const counts = {};
|
||||
counts.tables = await backfillTables();
|
||||
counts.fields = await backfillFields();
|
||||
counts.views = await backfillViews();
|
||||
counts.pages = await backfillPages();
|
||||
counts.triggers = await backfillTriggers();
|
||||
counts.roles = await backfillRoles();
|
||||
counts.library = await backfillLibrary();
|
||||
counts.tags = await backfillTags();
|
||||
counts.constraints = await backfillTableConstraints();
|
||||
counts.page_groups = await backfillPageGroups();
|
||||
counts.workflow_steps = await backfillWorkflowSteps();
|
||||
return counts;
|
||||
};
|
||||
|
||||
|
||||
module.exports = {
|
||||
backfillAll,
|
||||
ensureUuid,
|
||||
assignNewUuid,
|
||||
lookupByCurrent,
|
||||
lookupByUuid,
|
||||
adoptUuid,
|
||||
updateName,
|
||||
removeEntityRow,
|
||||
canonicalKey,
|
||||
constraintFingerprint,
|
||||
constraintDisplayName
|
||||
};
|
||||
63
dev-deploy/lib/env.js
Normal file
63
dev-deploy/lib/env.js
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
// This Saltcorn instance's dev-deploy identity (env_id, label, policies).
|
||||
// Stored as a singleton row in _dd_env.
|
||||
|
||||
const db = require("@saltcorn/data/db");
|
||||
|
||||
const { randomUuid } = require("./ids");
|
||||
const { DESTRUCTIVE_POLICY } = require("./constants");
|
||||
|
||||
|
||||
let cachedEnv = null;
|
||||
|
||||
|
||||
const getEnv = async () => {
|
||||
if (cachedEnv) {
|
||||
return cachedEnv;
|
||||
}
|
||||
const rows = await db.select("_dd_env", {});
|
||||
cachedEnv = rows.length > 0 ? rows[0] : null;
|
||||
return cachedEnv;
|
||||
};
|
||||
|
||||
|
||||
const refreshEnvCache = async () => {
|
||||
cachedEnv = null;
|
||||
return await getEnv();
|
||||
};
|
||||
|
||||
|
||||
const initEnvIfMissing = async () => {
|
||||
const existing = await getEnv();
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
const row = {
|
||||
env_id: randomUuid(),
|
||||
env_label: null,
|
||||
on_destructive_op: DESTRUCTIVE_POLICY.CONFIRM,
|
||||
require_tls: 0,
|
||||
created_at: now,
|
||||
bootstrapped_at: null
|
||||
};
|
||||
await db.insert("_dd_env", row, { noid: true });
|
||||
cachedEnv = row;
|
||||
return row;
|
||||
};
|
||||
|
||||
|
||||
const markBootstrapped = async (envId) => {
|
||||
const now = new Date().toISOString();
|
||||
await db.updateWhere("_dd_env", { bootstrapped_at: now }, { env_id: envId });
|
||||
if (cachedEnv && cachedEnv.env_id === envId) {
|
||||
cachedEnv.bootstrapped_at = now;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
module.exports = {
|
||||
getEnv,
|
||||
initEnvIfMissing,
|
||||
markBootstrapped,
|
||||
refreshEnvCache
|
||||
};
|
||||
60
dev-deploy/lib/files.js
Normal file
60
dev-deploy/lib/files.js
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
// File helpers: content hashing, relative-path normalization, reading bytes.
|
||||
|
||||
const crypto = require("crypto");
|
||||
const fs = require("fs");
|
||||
|
||||
|
||||
// SHA-256 of the file's contents, returned as lowercase hex.
|
||||
const sha256File = async (absPath) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = crypto.createHash("sha256");
|
||||
const stream = fs.createReadStream(absPath);
|
||||
stream.on("data", (chunk) => hash.update(chunk));
|
||||
stream.on("end", () => resolve(hash.digest("hex")));
|
||||
stream.on("error", reject);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const sha256Buffer = (buf) => {
|
||||
return crypto.createHash("sha256").update(buf).digest("hex");
|
||||
};
|
||||
|
||||
|
||||
// Convert Saltcorn's absolute file location to a tenant-relative serve path,
|
||||
// using File.absPathToServePath. The relative path is what we transport
|
||||
// between instances (each has a different file_store root).
|
||||
const toRelativePath = (File, absPath) => {
|
||||
if (!absPath) return "";
|
||||
return File.absPathToServePath(absPath);
|
||||
};
|
||||
|
||||
|
||||
// Convert a relative serve path back to the absolute path on this instance.
|
||||
const toAbsolutePath = (File, db, relPath) => {
|
||||
const path = require("path");
|
||||
const tenant = db.getTenantSchema();
|
||||
return path.join(db.connectObj.file_store, tenant, relPath);
|
||||
};
|
||||
|
||||
|
||||
const readFileBytes = async (absPath) => {
|
||||
return await fs.promises.readFile(absPath);
|
||||
};
|
||||
|
||||
|
||||
const writeFileBytes = async (absPath, buf) => {
|
||||
const path = require("path");
|
||||
await fs.promises.mkdir(path.dirname(absPath), { recursive: true });
|
||||
await fs.promises.writeFile(absPath, buf);
|
||||
};
|
||||
|
||||
|
||||
module.exports = {
|
||||
sha256File,
|
||||
sha256Buffer,
|
||||
toRelativePath,
|
||||
toAbsolutePath,
|
||||
readFileBytes,
|
||||
writeFileBytes
|
||||
};
|
||||
34
dev-deploy/lib/ids.js
Normal file
34
dev-deploy/lib/ids.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// UUID helpers for dev-deploy.
|
||||
//
|
||||
// Two flavors:
|
||||
// - randomUuid(): a fresh v4 UUID, used for newly-created ops and entities
|
||||
// - deterministicUuid(kind, name): a stable, repeatable UUID derived from
|
||||
// (namespace, kind, name) using SHA-256. Used during first-run backfill so
|
||||
// that two environments installed from the same base produce identical
|
||||
// UUIDs for the same (kind, name) pair. RFC 4122 v5 shape with namespace.
|
||||
|
||||
const crypto = require("crypto");
|
||||
|
||||
const { ID_NAMESPACE } = require("./constants");
|
||||
|
||||
|
||||
const deterministicUuid = (kind, name) => {
|
||||
const input = `${ID_NAMESPACE}|${kind}|${name}`;
|
||||
const hash = crypto.createHash("sha256").update(input).digest();
|
||||
const bytes = Buffer.from(hash.subarray(0, 16));
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x50;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
const hex = bytes.toString("hex");
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
||||
};
|
||||
|
||||
|
||||
const randomUuid = () => {
|
||||
return crypto.randomUUID();
|
||||
};
|
||||
|
||||
|
||||
module.exports = {
|
||||
deterministicUuid,
|
||||
randomUuid
|
||||
};
|
||||
47
dev-deploy/lib/ops.js
Normal file
47
dev-deploy/lib/ops.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// Journal append.
|
||||
|
||||
const db = require("@saltcorn/data/db");
|
||||
|
||||
const { getEnv } = require("./env");
|
||||
const { randomUuid } = require("./ids");
|
||||
const { OP_SCHEMA_VERSION } = require("./constants");
|
||||
const { currentParentOpId, currentCorrelationId } = require("./context");
|
||||
|
||||
|
||||
const recordOp = async (rec) => {
|
||||
const env = await getEnv();
|
||||
const now = new Date().toISOString();
|
||||
const row = {
|
||||
op_id: rec.op_id || randomUuid(),
|
||||
source_env_id: env.env_id,
|
||||
op_type: rec.op_type,
|
||||
entity_kind: rec.entity_kind || null,
|
||||
entity_uuid: rec.entity_uuid || null,
|
||||
payload: JSON.stringify(rec.payload || {}),
|
||||
parent_op_id: rec.parent_op_id || currentParentOpId(),
|
||||
correlation_id: rec.correlation_id || currentCorrelationId(),
|
||||
schema_version: OP_SCHEMA_VERSION,
|
||||
created_at: now,
|
||||
applied_at: now,
|
||||
status: "committed"
|
||||
};
|
||||
await db.insert("_dd_ops", row, { noid: true });
|
||||
return row.op_id;
|
||||
};
|
||||
|
||||
|
||||
const recordOpSafely = async (rec) => {
|
||||
try {
|
||||
return await recordOp(rec);
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[dev-deploy] failed to record op ${rec.op_type}:`, err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
module.exports = {
|
||||
recordOp,
|
||||
recordOpSafely
|
||||
};
|
||||
98
dev-deploy/lib/payloadRefs.js
Normal file
98
dev-deploy/lib/payloadRefs.js
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
// Recursive walker for op payloads: translate file references between this
|
||||
// instance's local form (numeric id or relative path) and a portable
|
||||
// placeholder ("__dd_file_ref::<uuid>"). Promote-time wraps call
|
||||
// toPlaceholders to journal portable payloads; apply-time handlers call
|
||||
// fromPlaceholders to resolve back to the target's local file path.
|
||||
//
|
||||
// Saltcorn's /files/serve route accepts either a numeric file id or a
|
||||
// relative path, so writing the path back on apply works regardless of which
|
||||
// form the source used.
|
||||
|
||||
const db = require("@saltcorn/data/db");
|
||||
|
||||
const { lookupByUuid } = require("./entityIds");
|
||||
|
||||
|
||||
// Common keys in page/view layout JSON that reference a file.
|
||||
const FILE_REF_KEYS = new Set([
|
||||
"fileid",
|
||||
"file_id",
|
||||
"bgFileId",
|
||||
"image_id"
|
||||
]);
|
||||
|
||||
|
||||
const PLACEHOLDER_PREFIX = "__dd_file_ref::";
|
||||
|
||||
|
||||
// Look up a file entity by value (string path or numeric id). Returns the
|
||||
// entity_ids row or null.
|
||||
const lookupFileByValue = async (v) => {
|
||||
if (v === null || v === undefined || v === "") return null;
|
||||
// Match by current_name (relative path string)
|
||||
let row = await db.selectMaybeOne("_dd_entity_ids", { kind: "file", current_name: String(v) });
|
||||
if (row) return row;
|
||||
// Also try numeric id, in case the source stored an integer id
|
||||
const asNum = Number(v);
|
||||
if (Number.isFinite(asNum)) {
|
||||
row = await db.selectMaybeOne("_dd_entity_ids", { kind: "file", current_id: asNum });
|
||||
if (row) return row;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
// Walk obj, mutating in place. For each key in FILE_REF_KEYS with a non-empty
|
||||
// value, replace with the result of `transform(key, value)`. Async-aware.
|
||||
const transformFileRefs = async (obj, transform) => {
|
||||
if (Array.isArray(obj)) {
|
||||
for (let i = 0; i < obj.length; i++) {
|
||||
if (obj[i] && typeof obj[i] === "object") {
|
||||
await transformFileRefs(obj[i], transform);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!obj || typeof obj !== "object") return;
|
||||
for (const k of Object.keys(obj)) {
|
||||
const v = obj[k];
|
||||
if (FILE_REF_KEYS.has(k) && v !== null && v !== undefined && v !== "" && typeof v !== "object") {
|
||||
obj[k] = await transform(k, v);
|
||||
} else if (v && typeof v === "object") {
|
||||
await transformFileRefs(v, transform);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Convert local file refs (id or path) -> portable placeholders.
|
||||
const toPlaceholders = async (payload) => {
|
||||
await transformFileRefs(payload, async (k, v) => {
|
||||
const ent = await lookupFileByValue(v);
|
||||
if (ent) return PLACEHOLDER_PREFIX + ent.uuid;
|
||||
return v;
|
||||
});
|
||||
return payload;
|
||||
};
|
||||
|
||||
|
||||
// Convert portable placeholders -> local file refs (write back as relative
|
||||
// path, which Saltcorn's /files/serve accepts).
|
||||
const fromPlaceholders = async (payload) => {
|
||||
await transformFileRefs(payload, async (k, v) => {
|
||||
if (typeof v !== "string") return v;
|
||||
if (!v.startsWith(PLACEHOLDER_PREFIX)) return v;
|
||||
const uuid = v.substring(PLACEHOLDER_PREFIX.length);
|
||||
const ent = await lookupByUuid(uuid);
|
||||
return ent ? ent.current_name : v;
|
||||
});
|
||||
return payload;
|
||||
};
|
||||
|
||||
|
||||
module.exports = {
|
||||
toPlaceholders,
|
||||
fromPlaceholders,
|
||||
PLACEHOLDER_PREFIX,
|
||||
FILE_REF_KEYS
|
||||
};
|
||||
115
dev-deploy/lib/peerAuth.js
Normal file
115
dev-deploy/lib/peerAuth.js
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
// Verify incoming HMAC-signed peer requests.
|
||||
//
|
||||
// Required headers:
|
||||
// X-DD-Env-Id caller's env UUID (looked up in _dd_peers)
|
||||
// X-DD-Timestamp ms-since-epoch; rejected if skew > 5 min
|
||||
// X-DD-Nonce random per-request opaque bytes (replay padding)
|
||||
// X-DD-Signature hex HMAC-SHA256 over canonical string
|
||||
//
|
||||
// On success: req.dd_peer is set (peer row), req.body is the parsed JSON body,
|
||||
// and the function returns the peer. On failure: a 4xx response is sent and
|
||||
// null is returned.
|
||||
//
|
||||
// Peer requests use Content-Type: application/vnd.dev-deploy+json so Saltcorn's
|
||||
// express.json() middleware doesn't consume the stream upstream. That lets us
|
||||
// read the exact raw bytes here and use them in the HMAC -- no JSON-canonical
|
||||
// fragility, no re-serialization assumptions about whitespace or key order.
|
||||
|
||||
const {
|
||||
buildCanonical,
|
||||
verifySignature,
|
||||
timestampWithinSkew
|
||||
} = require("./crypto");
|
||||
const {
|
||||
findPeerByEnvId,
|
||||
peerSecret,
|
||||
touchPeerLastSeen
|
||||
} = require("./peers");
|
||||
|
||||
|
||||
const REQUIRED_HEADERS = ["x-dd-env-id", "x-dd-timestamp", "x-dd-nonce", "x-dd-signature"];
|
||||
|
||||
|
||||
const readRawBody = async (req) => {
|
||||
const chunks = [];
|
||||
for await (const chunk of req) {
|
||||
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
||||
}
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
};
|
||||
|
||||
|
||||
const requirePeerAuth = async (req, res) => {
|
||||
for (const h of REQUIRED_HEADERS) {
|
||||
if (!req.headers[h]) {
|
||||
res.status(400).json({ error: `missing header ${h}` });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const callerEnvId = req.headers["x-dd-env-id"];
|
||||
const timestamp = req.headers["x-dd-timestamp"];
|
||||
const nonce = req.headers["x-dd-nonce"];
|
||||
const signature = req.headers["x-dd-signature"];
|
||||
|
||||
if (!timestampWithinSkew(timestamp)) {
|
||||
res.status(401).json({ error: "timestamp out of skew window" });
|
||||
return null;
|
||||
}
|
||||
|
||||
const peerRow = await findPeerByEnvId(callerEnvId);
|
||||
if (!peerRow) {
|
||||
res.status(401).json({ error: "unknown peer env_id" });
|
||||
return null;
|
||||
}
|
||||
|
||||
let secret;
|
||||
try {
|
||||
secret = await peerSecret(peerRow.peer_id);
|
||||
} catch (e) {
|
||||
res.status(401).json({ error: "peer not provisioned" });
|
||||
return null;
|
||||
}
|
||||
|
||||
// Read raw bytes (empty for GET/HEAD). HMAC covers exactly what arrived.
|
||||
let bodyRaw = "";
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
try {
|
||||
bodyRaw = await readRawBody(req);
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: "failed to read request body" });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const fullPath = req.originalUrl || req.url;
|
||||
const canonical = buildCanonical({
|
||||
timestamp: timestamp,
|
||||
nonce: nonce,
|
||||
method: req.method,
|
||||
path: fullPath,
|
||||
body: bodyRaw
|
||||
});
|
||||
|
||||
if (!verifySignature(secret, canonical, signature)) {
|
||||
res.status(401).json({ error: "bad signature" });
|
||||
return null;
|
||||
}
|
||||
|
||||
if (bodyRaw) {
|
||||
try {
|
||||
req.body = JSON.parse(bodyRaw);
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: "body is not valid JSON" });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
await touchPeerLastSeen(peerRow.peer_id);
|
||||
req.dd_peer = peerRow;
|
||||
return peerRow;
|
||||
};
|
||||
|
||||
|
||||
module.exports = {
|
||||
requirePeerAuth
|
||||
};
|
||||
142
dev-deploy/lib/peers.js
Normal file
142
dev-deploy/lib/peers.js
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
// Peer model: CRUD on _dd_peers.
|
||||
//
|
||||
// Peer secrets are stored AES-256-GCM sealed; plaintext only crosses the
|
||||
// process boundary at pairing time (when the operator copies the secret to
|
||||
// the other instance's UI) and at HMAC sign/verify time.
|
||||
|
||||
const db = require("@saltcorn/data/db");
|
||||
|
||||
const {
|
||||
seal,
|
||||
open,
|
||||
randomSecret
|
||||
} = require("./crypto");
|
||||
|
||||
|
||||
const rowToPeer = (row) => {
|
||||
if (!row) return null;
|
||||
return {
|
||||
peer_id: row.peer_id,
|
||||
env_id: row.env_id,
|
||||
label: row.label,
|
||||
base_url: row.base_url,
|
||||
require_tls: !!row.require_tls,
|
||||
created_at: row.created_at,
|
||||
last_seen_at: row.last_seen_at,
|
||||
// sealed components kept out of plain accessors -- use peerSecret()
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
const listPeers = async () => {
|
||||
const rows = await db.select("_dd_peers", {}, { orderBy: "peer_id" });
|
||||
return rows.map(rowToPeer);
|
||||
};
|
||||
|
||||
|
||||
const findPeer = async (peerId) => {
|
||||
const row = await db.selectMaybeOne("_dd_peers", { peer_id: peerId });
|
||||
return rowToPeer(row);
|
||||
};
|
||||
|
||||
|
||||
const findPeerByEnvId = async (envId) => {
|
||||
return await db.selectMaybeOne("_dd_peers", { env_id: envId });
|
||||
};
|
||||
|
||||
|
||||
// Returns the plaintext 32-byte secret for an existing peer by id.
|
||||
// Throws if the peer is missing or has no sealed secret.
|
||||
const peerSecret = async (peerId) => {
|
||||
const row = await db.selectMaybeOne("_dd_peers", { peer_id: peerId });
|
||||
if (!row) {
|
||||
throw new Error(`peer ${peerId} not found`);
|
||||
}
|
||||
if (!row.peer_secret_ciphertext || !row.peer_secret_iv || !row.peer_secret_tag) {
|
||||
throw new Error(`peer ${peerId} has no sealed secret`);
|
||||
}
|
||||
return open({
|
||||
ciphertext: Buffer.from(row.peer_secret_ciphertext, "hex"),
|
||||
iv: Buffer.from(row.peer_secret_iv, "hex"),
|
||||
tag: Buffer.from(row.peer_secret_tag, "hex")
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const peerSecretByEnvId = async (envId) => {
|
||||
const row = await findPeerByEnvId(envId);
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
return await peerSecret(row.peer_id);
|
||||
};
|
||||
|
||||
|
||||
// Create a new peer. If `existingSecret` is provided, use it; otherwise
|
||||
// generate a fresh one. Returns { peer, secret } where secret is the plaintext
|
||||
// Buffer -- only available at this single moment.
|
||||
const addPeer = async ({ envId, label, baseUrl, requireTls, existingSecret }) => {
|
||||
if (!envId || !baseUrl) {
|
||||
throw new Error("addPeer requires envId and baseUrl");
|
||||
}
|
||||
const dup = await findPeerByEnvId(envId);
|
||||
if (dup) {
|
||||
throw new Error(`peer with env_id ${envId} already exists`);
|
||||
}
|
||||
const secret = existingSecret || randomSecret();
|
||||
const sealed = seal(secret);
|
||||
const row = {
|
||||
env_id: envId,
|
||||
label: label || null,
|
||||
base_url: baseUrl,
|
||||
peer_secret_ciphertext: sealed.ciphertext.toString("hex"),
|
||||
peer_secret_iv: sealed.iv.toString("hex"),
|
||||
peer_secret_tag: sealed.tag.toString("hex"),
|
||||
require_tls: requireTls ? 1 : 0,
|
||||
created_at: new Date().toISOString(),
|
||||
last_seen_at: null
|
||||
};
|
||||
await db.insert("_dd_peers", row);
|
||||
const fresh = await findPeerByEnvId(envId);
|
||||
return { peer: rowToPeer(fresh), secret: secret };
|
||||
};
|
||||
|
||||
|
||||
const rotatePeerSecret = async (peerId) => {
|
||||
const peer = await findPeer(peerId);
|
||||
if (!peer) {
|
||||
throw new Error(`peer ${peerId} not found`);
|
||||
}
|
||||
const secret = randomSecret();
|
||||
const sealed = seal(secret);
|
||||
await db.updateWhere("_dd_peers", {
|
||||
peer_secret_ciphertext: sealed.ciphertext.toString("hex"),
|
||||
peer_secret_iv: sealed.iv.toString("hex"),
|
||||
peer_secret_tag: sealed.tag.toString("hex")
|
||||
}, { peer_id: peerId });
|
||||
return { peer: peer, secret: secret };
|
||||
};
|
||||
|
||||
|
||||
const deletePeer = async (peerId) => {
|
||||
await db.deleteWhere("_dd_peers", { peer_id: peerId });
|
||||
await db.deleteWhere("_dd_anchors", { peer_id: peerId });
|
||||
};
|
||||
|
||||
|
||||
const touchPeerLastSeen = async (peerId) => {
|
||||
await db.updateWhere("_dd_peers", { last_seen_at: new Date().toISOString() }, { peer_id: peerId });
|
||||
};
|
||||
|
||||
|
||||
module.exports = {
|
||||
listPeers,
|
||||
findPeer,
|
||||
findPeerByEnvId,
|
||||
peerSecret,
|
||||
peerSecretByEnvId,
|
||||
addPeer,
|
||||
rotatePeerSecret,
|
||||
deletePeer,
|
||||
touchPeerLastSeen
|
||||
};
|
||||
205
dev-deploy/lib/revert.js
Normal file
205
dev-deploy/lib/revert.js
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
// Revert: append a compensating op for any local op_id.
|
||||
//
|
||||
// create_X → drop the entity that was created
|
||||
// drop_X → recreate the entity from payload.before
|
||||
// update_X → re-apply payload.before as a new patch
|
||||
//
|
||||
// The revert calls the same Saltcorn model methods the admin UI does, so the
|
||||
// existing CRUD wraps fire and journal the inverse op naturally with this
|
||||
// instance's env_id. The inverse op then promotes to peers like any other op.
|
||||
//
|
||||
// Caveats:
|
||||
// - Reverting a drop produces a *new* entity with a new random UUID. The
|
||||
// original UUID is gone for good. Content is restored; identity is fresh.
|
||||
// - Reverting a field/view/trigger drop requires the parent table to still
|
||||
// exist locally. parent_uuid was captured by the drop wrap; we resolve it
|
||||
// to the current local id.
|
||||
|
||||
const Table = require("@saltcorn/data/models/table");
|
||||
const Field = require("@saltcorn/data/models/field");
|
||||
const View = require("@saltcorn/data/models/view");
|
||||
const Page = require("@saltcorn/data/models/page");
|
||||
const Trigger = require("@saltcorn/data/models/trigger");
|
||||
const Role = require("@saltcorn/data/models/role");
|
||||
const Library = require("@saltcorn/data/models/library");
|
||||
const Tag = require("@saltcorn/data/models/tag");
|
||||
|
||||
const db = require("@saltcorn/data/db");
|
||||
|
||||
const { lookupByUuid } = require("./entityIds");
|
||||
const { ENTITY_KINDS } = require("./constants");
|
||||
|
||||
|
||||
const MODELS = {
|
||||
[ENTITY_KINDS.TABLE]: Table,
|
||||
[ENTITY_KINDS.FIELD]: Field,
|
||||
[ENTITY_KINDS.VIEW]: View,
|
||||
[ENTITY_KINDS.PAGE]: Page,
|
||||
[ENTITY_KINDS.TRIGGER]: Trigger,
|
||||
[ENTITY_KINDS.ROLE]: Role,
|
||||
[ENTITY_KINDS.LIBRARY]: Library,
|
||||
[ENTITY_KINDS.TAG]: Tag
|
||||
};
|
||||
|
||||
|
||||
const stripIds = (obj) => {
|
||||
const out = { ...(obj || {}) };
|
||||
for (const k of ["id", "table_id", "view_id", "page_id"]) delete out[k];
|
||||
return out;
|
||||
};
|
||||
|
||||
|
||||
const { refreshState } = require("./state");
|
||||
|
||||
|
||||
const findLocalInstance = async (kind, entityUuid) => {
|
||||
const m = await lookupByUuid(entityUuid);
|
||||
if (!m) return null;
|
||||
const Cls = MODELS[kind];
|
||||
if (!Cls || typeof Cls.findOne !== "function") return null;
|
||||
await refreshState();
|
||||
const inst = await Cls.findOne({ id: m.current_id });
|
||||
return inst || null;
|
||||
};
|
||||
|
||||
|
||||
// --- Drop the entity (revert of create_X) ---
|
||||
const revertCreate = async (kind, op) => {
|
||||
const inst = await findLocalInstance(kind, op.entity_uuid);
|
||||
if (!inst) {
|
||||
return { status: "noop", reason: "entity no longer present" };
|
||||
}
|
||||
await inst.delete();
|
||||
return { status: "dropped" };
|
||||
};
|
||||
|
||||
|
||||
// --- Recreate from before-snapshot (revert of drop_X) ---
|
||||
const recreateFromSnapshot = async (kind, payload) => {
|
||||
const before = stripIds(payload.before || {});
|
||||
const parentUuid = payload.parent_uuid;
|
||||
|
||||
let parentLocalId = null;
|
||||
if (parentUuid) {
|
||||
const parent = await lookupByUuid(parentUuid);
|
||||
if (!parent) {
|
||||
throw new Error(`parent uuid=${parentUuid} not present locally; cannot recreate ${kind}`);
|
||||
}
|
||||
parentLocalId = parent.current_id;
|
||||
}
|
||||
|
||||
switch (kind) {
|
||||
case ENTITY_KINDS.TABLE: {
|
||||
if (!before.name) throw new Error("missing before.name");
|
||||
const opts = { ...before };
|
||||
const name = opts.name;
|
||||
delete opts.name;
|
||||
await Table.create(name, opts);
|
||||
return { status: "recreated" };
|
||||
}
|
||||
case ENTITY_KINDS.FIELD: {
|
||||
const cfg = { ...before, table_id: parentLocalId };
|
||||
await Field.create(cfg);
|
||||
return { status: "recreated" };
|
||||
}
|
||||
case ENTITY_KINDS.VIEW: {
|
||||
const cfg = { ...before };
|
||||
if (parentLocalId) cfg.table_id = parentLocalId;
|
||||
await View.create(cfg);
|
||||
return { status: "recreated" };
|
||||
}
|
||||
case ENTITY_KINDS.PAGE: {
|
||||
await Page.create(before);
|
||||
return { status: "recreated" };
|
||||
}
|
||||
case ENTITY_KINDS.TRIGGER: {
|
||||
const cfg = { ...before };
|
||||
if (parentLocalId) cfg.table_id = parentLocalId;
|
||||
await Trigger.create(cfg);
|
||||
return { status: "recreated" };
|
||||
}
|
||||
case ENTITY_KINDS.ROLE: {
|
||||
if (!before.id || !before.role) throw new Error("missing role identity");
|
||||
await Role.create({ id: before.id, role: before.role });
|
||||
return { status: "recreated" };
|
||||
}
|
||||
case ENTITY_KINDS.LIBRARY: {
|
||||
await Library.create(before);
|
||||
return { status: "recreated" };
|
||||
}
|
||||
case ENTITY_KINDS.TAG: {
|
||||
await Tag.create(before);
|
||||
return { status: "recreated" };
|
||||
}
|
||||
default:
|
||||
throw new Error(`unknown kind ${kind}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// --- Re-apply payload.before as a new patch (revert of update_X) ---
|
||||
const reapplyBefore = async (kind, op, payload) => {
|
||||
const inst = await findLocalInstance(kind, op.entity_uuid);
|
||||
if (!inst) {
|
||||
throw new Error(`entity_uuid=${op.entity_uuid} (${kind}) not present locally`);
|
||||
}
|
||||
const patch = stripIds(payload.before || {});
|
||||
if (Object.keys(patch).length === 0) {
|
||||
return { status: "noop", reason: "empty before-snapshot" };
|
||||
}
|
||||
switch (kind) {
|
||||
case ENTITY_KINDS.TABLE:
|
||||
case ENTITY_KINDS.FIELD:
|
||||
case ENTITY_KINDS.ROLE:
|
||||
case ENTITY_KINDS.LIBRARY:
|
||||
case ENTITY_KINDS.TAG:
|
||||
await inst.update(patch);
|
||||
return { status: "updated" };
|
||||
case ENTITY_KINDS.VIEW:
|
||||
await View.update(patch, inst.id);
|
||||
return { status: "updated" };
|
||||
case ENTITY_KINDS.PAGE:
|
||||
await Page.update(inst.id, patch);
|
||||
return { status: "updated" };
|
||||
case ENTITY_KINDS.TRIGGER:
|
||||
await Trigger.update(inst.id, patch);
|
||||
return { status: "updated" };
|
||||
default:
|
||||
throw new Error(`unknown kind ${kind}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const ACTION_HANDLERS = {
|
||||
create: revertCreate,
|
||||
drop: recreateFromSnapshot,
|
||||
update: reapplyBefore
|
||||
};
|
||||
|
||||
|
||||
// Look up the original op, parse its payload, dispatch to the inverse action.
|
||||
// The local wrap journals the inverse as a new op with a fresh op_id.
|
||||
const revertOp = async (opId) => {
|
||||
const orig = await db.selectMaybeOne("_dd_ops", { op_id: opId });
|
||||
if (!orig) {
|
||||
throw new Error(`op ${opId} not found`);
|
||||
}
|
||||
const [action, kind] = (orig.op_type || "").split("_", 2);
|
||||
const handler = ACTION_HANDLERS[action];
|
||||
if (!handler) {
|
||||
throw new Error(`no revert handler for action '${action}' (op_type=${orig.op_type})`);
|
||||
}
|
||||
if (!MODELS[kind]) {
|
||||
throw new Error(`no model for kind '${kind}'`);
|
||||
}
|
||||
const payload = typeof orig.payload === "string" ? JSON.parse(orig.payload) : (orig.payload || {});
|
||||
if (action === "drop") {
|
||||
return await handler(kind, payload);
|
||||
}
|
||||
return await handler(kind, orig, payload);
|
||||
};
|
||||
|
||||
|
||||
module.exports = {
|
||||
revertOp
|
||||
};
|
||||
1060
dev-deploy/lib/routes.js
Normal file
1060
dev-deploy/lib/routes.js
Normal file
File diff suppressed because it is too large
Load diff
124
dev-deploy/lib/rowIdentity.js
Normal file
124
dev-deploy/lib/rowIdentity.js
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
// Hidden _dd_row_uuid column infrastructure for managed/starter tables.
|
||||
//
|
||||
// When an admin marks a user table as managed or starter, this module
|
||||
// adds a TEXT column _dd_row_uuid to the underlying SQL table via raw ALTER
|
||||
// (NOT registered in _sc_fields, so Saltcorn's table builder doesn't show
|
||||
// it). Existing rows are backfilled with random UUIDs. From that point,
|
||||
// the wrap layer reads/writes _dd_row_uuid as the cross-environment identity
|
||||
// for each row.
|
||||
|
||||
const crypto = require("crypto");
|
||||
|
||||
const db = require("@saltcorn/data/db");
|
||||
|
||||
|
||||
const COLUMN_NAME = "_dd_row_uuid";
|
||||
|
||||
|
||||
const tableSqlRef = (tableName) => {
|
||||
const schema = db.getTenantSchemaPrefix();
|
||||
return `${schema}"${db.sqlsanitize(tableName)}"`;
|
||||
};
|
||||
|
||||
|
||||
const columnExists = async (tableName) => {
|
||||
if (db.isSQLite) {
|
||||
const rs = await db.query(`PRAGMA table_info("${db.sqlsanitize(tableName)}")`);
|
||||
return rs.rows.some((r) => r.name === COLUMN_NAME);
|
||||
}
|
||||
const rs = await db.query(
|
||||
`SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = $1
|
||||
AND column_name = $2`,
|
||||
[tableName, COLUMN_NAME]
|
||||
);
|
||||
return rs.rows.length > 0;
|
||||
};
|
||||
|
||||
|
||||
const ensureManagedSchema = async (tableName) => {
|
||||
if (await columnExists(tableName)) {
|
||||
return { added: false };
|
||||
}
|
||||
await db.query(`ALTER TABLE ${tableSqlRef(tableName)} ADD COLUMN ${COLUMN_NAME} TEXT`);
|
||||
// Backfill existing rows with random UUIDs.
|
||||
const rs = await db.query(`SELECT id FROM ${tableSqlRef(tableName)} WHERE ${COLUMN_NAME} IS NULL`);
|
||||
let backfilled = 0;
|
||||
for (const r of rs.rows) {
|
||||
await db.query(
|
||||
`UPDATE ${tableSqlRef(tableName)} SET ${COLUMN_NAME} = $1 WHERE id = $2`,
|
||||
[crypto.randomUUID(), r.id]
|
||||
);
|
||||
backfilled++;
|
||||
}
|
||||
// Index for fast lookups by uuid.
|
||||
await db.query(
|
||||
`CREATE INDEX IF NOT EXISTS "${db.sqlsanitize(tableName)}_dd_row_uuid_idx"
|
||||
ON ${tableSqlRef(tableName)} (${COLUMN_NAME})`
|
||||
).catch(() => {});
|
||||
return { added: true, backfilled: backfilled };
|
||||
};
|
||||
|
||||
|
||||
const dropManagedSchema = async (tableName) => {
|
||||
if (!(await columnExists(tableName))) {
|
||||
return { dropped: false };
|
||||
}
|
||||
// SQLite 3.35+ and Postgres both support DROP COLUMN. If the SQLite is
|
||||
// older it will throw — surface the error.
|
||||
await db.query(`ALTER TABLE ${tableSqlRef(tableName)} DROP COLUMN ${COLUMN_NAME}`);
|
||||
return { dropped: true };
|
||||
};
|
||||
|
||||
|
||||
const getRowUuid = async (tableName, id) => {
|
||||
const rs = await db.query(
|
||||
`SELECT ${COLUMN_NAME} AS uuid FROM ${tableSqlRef(tableName)} WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
return rs.rows.length > 0 ? rs.rows[0].uuid : null;
|
||||
};
|
||||
|
||||
|
||||
const findIdByRowUuid = async (tableName, uuid) => {
|
||||
const rs = await db.query(
|
||||
`SELECT id FROM ${tableSqlRef(tableName)} WHERE ${COLUMN_NAME} = $1`,
|
||||
[uuid]
|
||||
);
|
||||
return rs.rows.length > 0 ? rs.rows[0].id : null;
|
||||
};
|
||||
|
||||
|
||||
const setRowUuid = async (tableName, id, uuid) => {
|
||||
await db.query(
|
||||
`UPDATE ${tableSqlRef(tableName)} SET ${COLUMN_NAME} = $1 WHERE id = $2`,
|
||||
[uuid, id]
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const newRowUuid = () => crypto.randomUUID();
|
||||
|
||||
|
||||
// All rows currently in the table, including their _dd_row_uuid. Used during
|
||||
// the initial managed/starter ship to journal an insert_row op for each.
|
||||
const allRowsWithUuid = async (tableName) => {
|
||||
const rs = await db.query(
|
||||
`SELECT * FROM ${tableSqlRef(tableName)} ORDER BY id ASC`
|
||||
);
|
||||
return rs.rows;
|
||||
};
|
||||
|
||||
|
||||
module.exports = {
|
||||
COLUMN_NAME,
|
||||
columnExists,
|
||||
ensureManagedSchema,
|
||||
dropManagedSchema,
|
||||
getRowUuid,
|
||||
findIdByRowUuid,
|
||||
setRowUuid,
|
||||
newRowUuid,
|
||||
allRowsWithUuid
|
||||
};
|
||||
144
dev-deploy/lib/rowPayload.js
Normal file
144
dev-deploy/lib/rowPayload.js
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
// Row payload translation: FK ids -> row uuids (on journal) and back (on apply).
|
||||
//
|
||||
// For each FK field on the row's table (field.is_fkey), look up the referenced
|
||||
// row's _dd_row_uuid. Store under "<fieldname>__uuid" to avoid colliding with
|
||||
// the original field name. On apply, resolve back to the local row id.
|
||||
//
|
||||
// FKs to user-mode tables are NULLed on the target, with a warning attached
|
||||
// to the payload so it surfaces in the admin UI.
|
||||
|
||||
const db = require("@saltcorn/data/db");
|
||||
|
||||
const { getRowUuid, findIdByRowUuid, COLUMN_NAME } = require("./rowIdentity");
|
||||
const { lookupByCurrent } = require("./entityIds");
|
||||
|
||||
|
||||
const UUID_SUFFIX = "__uuid";
|
||||
|
||||
|
||||
// Returns data_mode for the table with given current_id (Saltcorn table id).
|
||||
// 'user' (default), 'starter', or 'managed'.
|
||||
const tableModeByCurrentId = async (tableId) => {
|
||||
const ent = await lookupByCurrent("table", tableId);
|
||||
if (!ent) return "user";
|
||||
const row = await db.selectMaybeOne("_dd_table_modes", { table_uuid: ent.uuid });
|
||||
return row ? row.data_mode : "user";
|
||||
};
|
||||
|
||||
|
||||
const tableModeByUuid = async (tableUuid) => {
|
||||
if (!tableUuid) return "user";
|
||||
const row = await db.selectMaybeOne("_dd_table_modes", { table_uuid: tableUuid });
|
||||
return row ? row.data_mode : "user";
|
||||
};
|
||||
|
||||
|
||||
// rowData: a plain row from the table.
|
||||
// table: the Saltcorn Table instance whose fields define the FK shape.
|
||||
// Returns { portable: {...with __uuid keys for FKs}, warnings: [...] }.
|
||||
const rowToPortable = async (rowData, table) => {
|
||||
const portable = {};
|
||||
const warnings = [];
|
||||
for (const field of table.fields || []) {
|
||||
if (field.name === COLUMN_NAME || field.name === "id") continue;
|
||||
const v = rowData[field.name];
|
||||
if (field.is_fkey && field.reftable_name && v !== null && v !== undefined) {
|
||||
const refMode = await tableModeByCurrentId_byName(field.reftable_name);
|
||||
if (refMode === "managed" || refMode === "starter") {
|
||||
const refUuid = await getRowUuid(field.reftable_name, v);
|
||||
if (refUuid) {
|
||||
portable[field.name + UUID_SUFFIX] = refUuid;
|
||||
} else {
|
||||
portable[field.name + UUID_SUFFIX] = null;
|
||||
warnings.push(`${field.name}: source row references ${field.reftable_name}.id=${v} but it has no _dd_row_uuid yet`);
|
||||
}
|
||||
} else {
|
||||
// FK into a user-mode table — can't translate. Drop and warn.
|
||||
portable[field.name + UUID_SUFFIX] = null;
|
||||
warnings.push(`${field.name} → user-mode table ${field.reftable_name}; FK will be null on target`);
|
||||
}
|
||||
} else {
|
||||
portable[field.name] = v;
|
||||
}
|
||||
}
|
||||
return { portable: portable, warnings: warnings };
|
||||
};
|
||||
|
||||
|
||||
// portable: payload from a journaled row op.
|
||||
// table: the Saltcorn Table instance on the local (target) side.
|
||||
const portableToRow = async (portable, table) => {
|
||||
const row = {};
|
||||
for (const field of table.fields || []) {
|
||||
if (field.name === COLUMN_NAME || field.name === "id") continue;
|
||||
const uuidKey = field.name + UUID_SUFFIX;
|
||||
if (uuidKey in portable) {
|
||||
const refUuid = portable[uuidKey];
|
||||
if (!refUuid) {
|
||||
row[field.name] = null;
|
||||
} else if (field.is_fkey && field.reftable_name) {
|
||||
const localId = await findIdByRowUuid(field.reftable_name, refUuid);
|
||||
row[field.name] = localId; // may be null if target doesn't have that row yet
|
||||
} else {
|
||||
row[field.name] = null;
|
||||
}
|
||||
} else if (field.name in portable) {
|
||||
row[field.name] = portable[field.name];
|
||||
}
|
||||
}
|
||||
return row;
|
||||
};
|
||||
|
||||
|
||||
// Helper: lookup mode by table name (via entity_ids).
|
||||
const tableModeByCurrentId_byName = async (tableName) => {
|
||||
const Table = require("@saltcorn/data/models/table");
|
||||
const t = Table.findOne({ name: tableName });
|
||||
if (!t) return "user";
|
||||
return await tableModeByCurrentId(t.id);
|
||||
};
|
||||
|
||||
|
||||
// True if a starter table has already had its initial-ship completed; managed
|
||||
// tables ignore this (they always journal).
|
||||
const isStarterShipped = async (tableUuid) => {
|
||||
const row = await db.selectMaybeOne("_dd_table_modes", { table_uuid: tableUuid });
|
||||
return !!(row && row.starter_shipped_at);
|
||||
};
|
||||
|
||||
|
||||
const markStarterShipped = async (tableUuid) => {
|
||||
await db.updateWhere("_dd_table_modes", { starter_shipped_at: new Date().toISOString() }, { table_uuid: tableUuid });
|
||||
};
|
||||
|
||||
|
||||
// For a given Saltcorn Table id, returns { mode, tableUuid, shouldJournal }.
|
||||
// shouldJournal is the wrap-level decision: true → record the row op; false →
|
||||
// pass through silently.
|
||||
const journalDecision = async (tableId) => {
|
||||
const { lookupByCurrent } = require("./entityIds");
|
||||
const ent = await lookupByCurrent("table", tableId);
|
||||
if (!ent) return { mode: "user", tableUuid: null, shouldJournal: false };
|
||||
const mode = await tableModeByUuid(ent.uuid);
|
||||
if (mode === "managed") {
|
||||
return { mode: mode, tableUuid: ent.uuid, shouldJournal: true };
|
||||
}
|
||||
if (mode === "starter") {
|
||||
const shipped = await isStarterShipped(ent.uuid);
|
||||
return { mode: mode, tableUuid: ent.uuid, shouldJournal: !shipped };
|
||||
}
|
||||
return { mode: "user", tableUuid: ent.uuid, shouldJournal: false };
|
||||
};
|
||||
|
||||
|
||||
module.exports = {
|
||||
rowToPortable,
|
||||
portableToRow,
|
||||
tableModeByCurrentId,
|
||||
tableModeByUuid,
|
||||
tableModeByCurrentId_byName,
|
||||
isStarterShipped,
|
||||
markStarterShipped,
|
||||
journalDecision,
|
||||
UUID_SUFFIX
|
||||
};
|
||||
140
dev-deploy/lib/schema.js
Normal file
140
dev-deploy/lib/schema.js
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
// DDL for dev-deploy's six plugin tables.
|
||||
//
|
||||
// Saltcorn supports SQLite and PostgreSQL. We use a portable subset:
|
||||
// - TEXT for all strings, JSON payloads, ISO 8601 timestamps
|
||||
// - INTEGER for booleans (0/1) and surrogate ids
|
||||
// - No JSONB; payloads are TEXT containing JSON, parsed/stringified at the
|
||||
// application layer
|
||||
//
|
||||
// Tables are created idempotently in onLoad. Re-running is safe.
|
||||
|
||||
const db = require("@saltcorn/data/db");
|
||||
|
||||
|
||||
const createDdEnv = async () => {
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS _dd_env (
|
||||
env_id TEXT PRIMARY KEY,
|
||||
env_label TEXT,
|
||||
on_destructive_op TEXT NOT NULL DEFAULT 'confirm',
|
||||
require_tls INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
bootstrapped_at TEXT
|
||||
)
|
||||
`);
|
||||
};
|
||||
|
||||
|
||||
const createDdPeers = async () => {
|
||||
// Secret components stored as hex TEXT rather than BLOB: Saltcorn's
|
||||
// SQLite insert layer JSON-stringifies any object value, which would
|
||||
// mangle Buffer columns. Hex is portable and survives that path intact.
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS _dd_peers (
|
||||
peer_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
env_id TEXT NOT NULL UNIQUE,
|
||||
label TEXT,
|
||||
base_url TEXT NOT NULL,
|
||||
peer_secret_ciphertext TEXT,
|
||||
peer_secret_iv TEXT,
|
||||
peer_secret_tag TEXT,
|
||||
require_tls INTEGER,
|
||||
created_at TEXT NOT NULL,
|
||||
last_seen_at TEXT
|
||||
)
|
||||
`);
|
||||
};
|
||||
|
||||
|
||||
const createDdEntityIds = async () => {
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS _dd_entity_ids (
|
||||
uuid TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
current_name TEXT NOT NULL,
|
||||
current_id INTEGER NOT NULL,
|
||||
parent_uuid TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE (kind, current_id)
|
||||
)
|
||||
`);
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS _dd_entity_ids_kind_name ON _dd_entity_ids (kind, current_name)`);
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS _dd_entity_ids_parent ON _dd_entity_ids (parent_uuid)`);
|
||||
};
|
||||
|
||||
|
||||
const createDdOps = async () => {
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS _dd_ops (
|
||||
op_id TEXT PRIMARY KEY,
|
||||
source_env_id TEXT NOT NULL,
|
||||
op_type TEXT NOT NULL,
|
||||
entity_kind TEXT,
|
||||
entity_uuid TEXT,
|
||||
payload TEXT NOT NULL,
|
||||
parent_op_id TEXT,
|
||||
correlation_id TEXT,
|
||||
schema_version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
applied_at TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'committed',
|
||||
conflict_with_op_id TEXT
|
||||
)
|
||||
`);
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS _dd_ops_created ON _dd_ops (created_at)`);
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS _dd_ops_source ON _dd_ops (source_env_id, created_at)`);
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS _dd_ops_entity ON _dd_ops (entity_uuid)`);
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS _dd_ops_correlation ON _dd_ops (correlation_id)`);
|
||||
await db.query(`CREATE INDEX IF NOT EXISTS _dd_ops_status ON _dd_ops (status) WHERE status = 'conflict'`).catch(() => {});
|
||||
|
||||
// Idempotent migration for instances installed before conflict_with_op_id existed
|
||||
try {
|
||||
await db.query(`ALTER TABLE _dd_ops ADD COLUMN conflict_with_op_id TEXT`);
|
||||
} catch (e) {
|
||||
// column already exists; ignore
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const createDdAnchors = async () => {
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS _dd_anchors (
|
||||
peer_id INTEGER NOT NULL,
|
||||
direction TEXT NOT NULL,
|
||||
last_op_id TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (peer_id, direction)
|
||||
)
|
||||
`);
|
||||
};
|
||||
|
||||
|
||||
const createDdTableModes = async () => {
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS _dd_table_modes (
|
||||
table_uuid TEXT PRIMARY KEY,
|
||||
data_mode TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
starter_shipped_at TEXT
|
||||
)
|
||||
`);
|
||||
// Idempotent migration for older installs that lack starter_shipped_at.
|
||||
try {
|
||||
await db.query(`ALTER TABLE _dd_table_modes ADD COLUMN starter_shipped_at TEXT`);
|
||||
} catch (e) { /* column exists */ }
|
||||
};
|
||||
|
||||
|
||||
const createAllTables = async () => {
|
||||
await createDdEnv();
|
||||
await createDdPeers();
|
||||
await createDdEntityIds();
|
||||
await createDdOps();
|
||||
await createDdAnchors();
|
||||
await createDdTableModes();
|
||||
};
|
||||
|
||||
|
||||
module.exports = {
|
||||
createAllTables
|
||||
};
|
||||
27
dev-deploy/lib/state.js
Normal file
27
dev-deploy/lib/state.js
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// Shared helpers for Saltcorn state-cache interactions.
|
||||
|
||||
// Refresh Saltcorn's in-memory model caches. Mutations done in another process
|
||||
// (e.g. saltcorn run-js, prior ingest, or a peer's push) are not visible to
|
||||
// Model.findOne / Model.find until we refresh, because those methods read from
|
||||
// state.tables/views/pages/triggers arrays populated at startup.
|
||||
//
|
||||
// Best-effort: if Saltcorn changes its API and a refresh function disappears,
|
||||
// callers fall back to whatever's in the cache.
|
||||
const refreshState = async () => {
|
||||
try {
|
||||
const { getState } = require("@saltcorn/data/db/state");
|
||||
const s = getState();
|
||||
if (s.refresh_tables) await s.refresh_tables(true);
|
||||
if (s.refresh_views) await s.refresh_views(true);
|
||||
if (s.refresh_pages) await s.refresh_pages(true);
|
||||
if (s.refresh_triggers) await s.refresh_triggers(true);
|
||||
if (s.refresh_page_groups) await s.refresh_page_groups(true);
|
||||
} catch (e) {
|
||||
// ignore — best-effort
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
module.exports = {
|
||||
refreshState
|
||||
};
|
||||
98
dev-deploy/lib/transport.js
Normal file
98
dev-deploy/lib/transport.js
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
// Outbound signed peer requests.
|
||||
//
|
||||
// Build a canonical string, sign it with the peer's shared secret, and send
|
||||
// it via fetch. Body is JSON-serialized once and used for both the HTTP body
|
||||
// and the signature payload to keep client/server hash agreement trivial.
|
||||
|
||||
const {
|
||||
buildCanonical,
|
||||
sign,
|
||||
randomNonce
|
||||
} = require("./crypto");
|
||||
|
||||
|
||||
const signedFetch = async ({ baseUrl, method, path, body, sourceEnvId, secret }) => {
|
||||
const timestamp = String(Date.now());
|
||||
const nonce = randomNonce().toString("hex");
|
||||
const bodyStr = body ? JSON.stringify(body) : "";
|
||||
const canonical = buildCanonical({
|
||||
timestamp: timestamp,
|
||||
nonce: nonce,
|
||||
method: method,
|
||||
path: path,
|
||||
body: bodyStr
|
||||
});
|
||||
const signature = sign(secret, canonical);
|
||||
|
||||
const url = baseUrl.replace(/\/+$/, "") + path;
|
||||
const headers = {
|
||||
"X-DD-Env-Id": sourceEnvId,
|
||||
"X-DD-Timestamp": timestamp,
|
||||
"X-DD-Nonce": nonce,
|
||||
"X-DD-Signature": signature
|
||||
};
|
||||
if (bodyStr) {
|
||||
// Custom Content-Type so Saltcorn's express.json() middleware leaves the
|
||||
// request stream untouched; the server reads exact bytes for HMAC.
|
||||
headers["Content-Type"] = "application/vnd.dev-deploy+json";
|
||||
}
|
||||
|
||||
const init = { method: method, headers: headers };
|
||||
if (bodyStr) {
|
||||
init.body = bodyStr;
|
||||
}
|
||||
|
||||
const res = await fetch(url, init);
|
||||
const text = await res.text();
|
||||
let parsed = null;
|
||||
if (text) {
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch (e) {
|
||||
parsed = { raw: text };
|
||||
}
|
||||
}
|
||||
return { status: res.status, ok: res.ok, body: parsed };
|
||||
};
|
||||
|
||||
|
||||
// Like signedFetch but returns the response body as a Buffer (raw bytes).
|
||||
// For binary endpoints like GET /dev-deploy/api/file/:uuid.
|
||||
const signedFetchBinary = async ({ baseUrl, method, path, body, sourceEnvId, secret }) => {
|
||||
const timestamp = String(Date.now());
|
||||
const nonce = randomNonce().toString("hex");
|
||||
const bodyStr = body ? JSON.stringify(body) : "";
|
||||
const canonical = buildCanonical({
|
||||
timestamp: timestamp,
|
||||
nonce: nonce,
|
||||
method: method,
|
||||
path: path,
|
||||
body: bodyStr
|
||||
});
|
||||
const signature = sign(secret, canonical);
|
||||
|
||||
const url = baseUrl.replace(/\/+$/, "") + path;
|
||||
const headers = {
|
||||
"X-DD-Env-Id": sourceEnvId,
|
||||
"X-DD-Timestamp": timestamp,
|
||||
"X-DD-Nonce": nonce,
|
||||
"X-DD-Signature": signature
|
||||
};
|
||||
if (bodyStr) {
|
||||
headers["Content-Type"] = "application/vnd.dev-deploy+json";
|
||||
}
|
||||
|
||||
const init = { method: method, headers: headers };
|
||||
if (bodyStr) {
|
||||
init.body = bodyStr;
|
||||
}
|
||||
const res = await fetch(url, init);
|
||||
const bytes = Buffer.from(await res.arrayBuffer());
|
||||
return { status: res.status, ok: res.ok, bytes: bytes };
|
||||
};
|
||||
|
||||
|
||||
module.exports = {
|
||||
signedFetch,
|
||||
signedFetchBinary
|
||||
};
|
||||
999
dev-deploy/lib/wrap.js
Normal file
999
dev-deploy/lib/wrap.js
Normal file
|
|
@ -0,0 +1,999 @@
|
|||
// Monkey-patches Saltcorn metadata model classes to journal every CRUD action.
|
||||
//
|
||||
// Each wrap:
|
||||
// 1. Pre-generates an op_id and enters an AsyncLocalStorage scope so any
|
||||
// child mutations triggered by the original method (e.g. cascading field
|
||||
// deletes from a table delete) see this op as their parent.
|
||||
// 2. Optionally captures pre-state via a "before" hook.
|
||||
// 3. Invokes the original method.
|
||||
// 4. On success, runs an "after" hook to compute entity uuid + payload and
|
||||
// appends the op to _dd_ops.
|
||||
//
|
||||
// Failures inside the after-hook are logged but do not throw -- we never want
|
||||
// the journal to corrupt user-facing operations. Failures inside the original
|
||||
// method propagate normally (no op is recorded for an aborted mutation).
|
||||
|
||||
const Table = require("@saltcorn/data/models/table");
|
||||
const Field = require("@saltcorn/data/models/field");
|
||||
const View = require("@saltcorn/data/models/view");
|
||||
const Page = require("@saltcorn/data/models/page");
|
||||
const Trigger = require("@saltcorn/data/models/trigger");
|
||||
const Role = require("@saltcorn/data/models/role");
|
||||
const Library = require("@saltcorn/data/models/library");
|
||||
const Tag = require("@saltcorn/data/models/tag");
|
||||
const TableConstraint = require("@saltcorn/data/models/table_constraints");
|
||||
const File = require("@saltcorn/data/models/file");
|
||||
const PageGroup = require("@saltcorn/data/models/page_group");
|
||||
const PageGroupMember = require("@saltcorn/data/models/page_group_member");
|
||||
const WorkflowStep = require("@saltcorn/data/models/workflow_step");
|
||||
const Plugin = require("@saltcorn/data/models/plugin");
|
||||
const db = require("@saltcorn/data/db");
|
||||
|
||||
const { randomUuid } = require("./ids");
|
||||
const { enterOp, isSuppressed } = require("./context");
|
||||
const { recordOpSafely } = require("./ops");
|
||||
const {
|
||||
assignNewUuid,
|
||||
lookupByCurrent,
|
||||
updateName,
|
||||
removeEntityRow,
|
||||
constraintDisplayName
|
||||
} = require("./entityIds");
|
||||
const { ENTITY_KINDS, fileLocationToId } = require("./constants");
|
||||
const { sha256File, toRelativePath } = require("./files");
|
||||
const { toPlaceholders } = require("./payloadRefs");
|
||||
const {
|
||||
setRowUuid,
|
||||
getRowUuid,
|
||||
newRowUuid,
|
||||
COLUMN_NAME: ROW_UUID_COL
|
||||
} = require("./rowIdentity");
|
||||
const {
|
||||
rowToPortable,
|
||||
journalDecision
|
||||
} = require("./rowPayload");
|
||||
|
||||
|
||||
const snapshotInstance = (inst, keys) => {
|
||||
const out = {};
|
||||
for (const k of keys) {
|
||||
if (inst[k] !== undefined) {
|
||||
out[k] = inst[k];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
|
||||
// Shared before/after hooks for instance `delete()` methods.
|
||||
// Captures the entity's UUID, parent UUID (if any), and snapshot, then removes
|
||||
// the entity_ids row after the original delete completes so reused integer ids
|
||||
// don't collide. parent_uuid is preserved so revert can find the parent later.
|
||||
const standardDropHooks = (kind, keys) => ({
|
||||
before: async function () {
|
||||
const existing = await lookupByCurrent(kind, this.id);
|
||||
return {
|
||||
uuid: existing ? existing.uuid : null,
|
||||
currentId: this.id,
|
||||
parentUuid: existing ? existing.parent_uuid : null,
|
||||
snapshot: snapshotInstance(this, keys)
|
||||
};
|
||||
},
|
||||
after: async function ({ before }) {
|
||||
if (!before || !before.uuid) return null;
|
||||
await removeEntityRow(kind, before.currentId);
|
||||
return {
|
||||
entityUuid: before.uuid,
|
||||
payload: { before: before.snapshot, parent_uuid: before.parentUuid }
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const wrap = (target, method, kind, action, hooks) => {
|
||||
const original = target[method];
|
||||
if (typeof original !== "function") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[dev-deploy] no method ${kind}.${method} to wrap`);
|
||||
return;
|
||||
}
|
||||
if (original.__ddWrapped) {
|
||||
return;
|
||||
}
|
||||
const wrapped = async function (...args) {
|
||||
if (isSuppressed()) {
|
||||
return await original.apply(this, args);
|
||||
}
|
||||
const opId = randomUuid();
|
||||
const beforeCtx = hooks.before ? await safeCall(hooks.before, this, [args]) : null;
|
||||
return await enterOp(opId, async () => {
|
||||
const result = await original.apply(this, args);
|
||||
const details = await safeCall(hooks.after, this, [{ args: args, result: result, before: beforeCtx }]);
|
||||
if (details) {
|
||||
// Translate any local file refs (numeric ids or paths) in the
|
||||
// payload to portable __dd_file_ref::<uuid> placeholders so
|
||||
// the journal stores cross-environment-stable references.
|
||||
if (details.payload) {
|
||||
try {
|
||||
await toPlaceholders(details.payload);
|
||||
} catch (e) {
|
||||
// best-effort; payload still journals as-is
|
||||
}
|
||||
}
|
||||
await recordOpSafely({
|
||||
op_id: opId,
|
||||
op_type: `${action}_${kind}`,
|
||||
entity_kind: kind,
|
||||
entity_uuid: details.entityUuid,
|
||||
payload: details.payload
|
||||
});
|
||||
}
|
||||
return result;
|
||||
});
|
||||
};
|
||||
wrapped.__ddWrapped = true;
|
||||
wrapped.__ddOriginal = original;
|
||||
target[method] = wrapped;
|
||||
};
|
||||
|
||||
|
||||
const safeCall = async (fn, ctx, args) => {
|
||||
if (!fn) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return await fn.apply(ctx, args);
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[dev-deploy] hook error:`, err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Common keys to snapshot for each entity. Skips heavy/derived fields.
|
||||
const TABLE_KEYS = ["id", "name", "min_role_read", "min_role_write", "versioned", "description", "ownership_field_id", "ownership_formula", "external", "provider_name", "provider_cfg"];
|
||||
const FIELD_KEYS = ["id", "name", "label", "type", "table_id", "required", "is_unique", "calculated", "stored", "expression", "reftable_name", "reftype", "refname", "primary_key", "attributes"];
|
||||
const VIEW_KEYS = ["id", "name", "viewtemplate", "table_id", "configuration", "min_role", "default_render_page", "exttable_name", "description", "slug"];
|
||||
const PAGE_KEYS = ["id", "name", "title", "description", "min_role", "layout", "fixed_states", "menu_label"];
|
||||
const TRIGGER_KEYS = ["id", "name", "action", "when_trigger", "table_id", "configuration", "min_role", "description", "channel"];
|
||||
const ROLE_KEYS = ["id", "role"];
|
||||
const LIBRARY_KEYS = ["id", "name", "icon", "layout"];
|
||||
const TAG_KEYS = ["id", "name"];
|
||||
const CONSTRAINT_KEYS = ["id", "type", "table_id", "configuration"];
|
||||
const PAGE_GROUP_KEYS = ["id", "name", "description", "min_role", "random_allocation"];
|
||||
const WORKFLOW_STEP_KEYS = ["id", "name", "trigger_id", "action_name", "next_step", "initial_step", "configuration", "only_if"];
|
||||
|
||||
|
||||
// ----- Table -----
|
||||
|
||||
const wrapTable = () => {
|
||||
wrap(Table, "create", ENTITY_KINDS.TABLE, "create", {
|
||||
after: async ({ result }) => {
|
||||
if (!result || !result.id) return null;
|
||||
const uuid = await assignNewUuid(ENTITY_KINDS.TABLE, result.id, result.name, null);
|
||||
return {
|
||||
entityUuid: uuid,
|
||||
payload: { after: snapshotInstance(result, TABLE_KEYS) }
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
wrap(Table.prototype, "update", ENTITY_KINDS.TABLE, "update", {
|
||||
before: async function (args) {
|
||||
const existing = await lookupByCurrent(ENTITY_KINDS.TABLE, this.id);
|
||||
return {
|
||||
uuid: existing ? existing.uuid : null,
|
||||
oldName: existing ? existing.current_name : this.name,
|
||||
snapshot: snapshotInstance(this, TABLE_KEYS)
|
||||
};
|
||||
},
|
||||
after: async function ({ args, before }) {
|
||||
if (!before || !before.uuid) return null;
|
||||
const patch = args[0] || {};
|
||||
const newName = patch.name !== undefined ? patch.name : before.oldName;
|
||||
if (newName !== before.oldName) {
|
||||
await updateName(ENTITY_KINDS.TABLE, this.id, newName);
|
||||
}
|
||||
return {
|
||||
entityUuid: before.uuid,
|
||||
payload: {
|
||||
before: before.snapshot,
|
||||
patch: patch,
|
||||
after: snapshotInstance(this, TABLE_KEYS)
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
wrap(Table.prototype, "delete", ENTITY_KINDS.TABLE, "drop", {
|
||||
before: async function (args) {
|
||||
const existing = await lookupByCurrent(ENTITY_KINDS.TABLE, this.id);
|
||||
return {
|
||||
uuid: existing ? existing.uuid : null,
|
||||
currentId: this.id,
|
||||
snapshot: snapshotInstance(this, TABLE_KEYS),
|
||||
onlyForget: !!args[0]
|
||||
};
|
||||
},
|
||||
after: async function ({ before }) {
|
||||
if (!before || !before.uuid) return null;
|
||||
await removeEntityRow(ENTITY_KINDS.TABLE, before.currentId);
|
||||
return {
|
||||
entityUuid: before.uuid,
|
||||
payload: { before: before.snapshot, only_forget: before.onlyForget }
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
// ----- Field -----
|
||||
|
||||
const wrapField = () => {
|
||||
wrap(Field, "create", ENTITY_KINDS.FIELD, "create", {
|
||||
after: async ({ args, result }) => {
|
||||
if (!result || !result.id) return null;
|
||||
let parentUuid = null;
|
||||
if (result.table_id) {
|
||||
const t = await lookupByCurrent(ENTITY_KINDS.TABLE, result.table_id);
|
||||
parentUuid = t ? t.uuid : null;
|
||||
}
|
||||
const uuid = await assignNewUuid(ENTITY_KINDS.FIELD, result.id, result.name, parentUuid);
|
||||
return {
|
||||
entityUuid: uuid,
|
||||
payload: {
|
||||
after: snapshotInstance(result, FIELD_KEYS),
|
||||
parent_uuid: parentUuid
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
wrap(Field.prototype, "update", ENTITY_KINDS.FIELD, "update", {
|
||||
before: async function (args) {
|
||||
const existing = await lookupByCurrent(ENTITY_KINDS.FIELD, this.id);
|
||||
return {
|
||||
uuid: existing ? existing.uuid : null,
|
||||
oldName: existing ? existing.current_name : this.name,
|
||||
snapshot: snapshotInstance(this, FIELD_KEYS)
|
||||
};
|
||||
},
|
||||
after: async function ({ args, before }) {
|
||||
if (!before || !before.uuid) return null;
|
||||
const patch = args[0] || {};
|
||||
const newName = patch.name !== undefined ? patch.name : before.oldName;
|
||||
if (newName !== before.oldName) {
|
||||
await updateName(ENTITY_KINDS.FIELD, this.id, newName);
|
||||
}
|
||||
return {
|
||||
entityUuid: before.uuid,
|
||||
payload: {
|
||||
before: before.snapshot,
|
||||
patch: patch
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
wrap(Field.prototype, "delete", ENTITY_KINDS.FIELD, "drop", standardDropHooks(ENTITY_KINDS.FIELD, FIELD_KEYS));
|
||||
};
|
||||
|
||||
|
||||
// ----- View -----
|
||||
|
||||
const wrapView = () => {
|
||||
wrap(View, "create", ENTITY_KINDS.VIEW, "create", {
|
||||
after: async ({ result }) => {
|
||||
if (!result || !result.id) return null;
|
||||
let parentUuid = null;
|
||||
if (result.table_id) {
|
||||
const t = await lookupByCurrent(ENTITY_KINDS.TABLE, result.table_id);
|
||||
parentUuid = t ? t.uuid : null;
|
||||
}
|
||||
const uuid = await assignNewUuid(ENTITY_KINDS.VIEW, result.id, result.name, parentUuid);
|
||||
return {
|
||||
entityUuid: uuid,
|
||||
payload: { after: snapshotInstance(result, VIEW_KEYS), parent_uuid: parentUuid }
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// View.update is static (v, id)
|
||||
wrap(View, "update", ENTITY_KINDS.VIEW, "update", {
|
||||
before: async ({ args }) => {
|
||||
const patch = args[0] || {};
|
||||
const id = args[1];
|
||||
const existing = await lookupByCurrent(ENTITY_KINDS.VIEW, id);
|
||||
return {
|
||||
id: id,
|
||||
uuid: existing ? existing.uuid : null,
|
||||
oldName: existing ? existing.current_name : null,
|
||||
patch: patch
|
||||
};
|
||||
},
|
||||
after: async ({ before }) => {
|
||||
if (!before || !before.uuid) return null;
|
||||
const newName = before.patch.name !== undefined ? before.patch.name : before.oldName;
|
||||
if (newName !== before.oldName) {
|
||||
await updateName(ENTITY_KINDS.VIEW, before.id, newName);
|
||||
}
|
||||
return {
|
||||
entityUuid: before.uuid,
|
||||
payload: { patch: before.patch }
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
wrap(View.prototype, "delete", ENTITY_KINDS.VIEW, "drop", standardDropHooks(ENTITY_KINDS.VIEW, VIEW_KEYS));
|
||||
};
|
||||
|
||||
|
||||
// ----- Page -----
|
||||
|
||||
const wrapPage = () => {
|
||||
wrap(Page, "create", ENTITY_KINDS.PAGE, "create", {
|
||||
after: async ({ result }) => {
|
||||
if (!result || !result.id) return null;
|
||||
const uuid = await assignNewUuid(ENTITY_KINDS.PAGE, result.id, result.name, null);
|
||||
return { entityUuid: uuid, payload: { after: snapshotInstance(result, PAGE_KEYS) } };
|
||||
}
|
||||
});
|
||||
|
||||
// Page.update is static (id, row)
|
||||
wrap(Page, "update", ENTITY_KINDS.PAGE, "update", {
|
||||
before: async ({ args }) => {
|
||||
const id = args[0];
|
||||
const row = args[1] || {};
|
||||
const existing = await lookupByCurrent(ENTITY_KINDS.PAGE, id);
|
||||
return {
|
||||
id: id,
|
||||
uuid: existing ? existing.uuid : null,
|
||||
oldName: existing ? existing.current_name : null,
|
||||
patch: row
|
||||
};
|
||||
},
|
||||
after: async ({ before }) => {
|
||||
if (!before || !before.uuid) return null;
|
||||
const newName = before.patch.name !== undefined ? before.patch.name : before.oldName;
|
||||
if (newName !== before.oldName) {
|
||||
await updateName(ENTITY_KINDS.PAGE, before.id, newName);
|
||||
}
|
||||
return { entityUuid: before.uuid, payload: { patch: before.patch } };
|
||||
}
|
||||
});
|
||||
|
||||
wrap(Page.prototype, "delete", ENTITY_KINDS.PAGE, "drop", standardDropHooks(ENTITY_KINDS.PAGE, PAGE_KEYS));
|
||||
};
|
||||
|
||||
|
||||
// ----- Trigger -----
|
||||
|
||||
const wrapTrigger = () => {
|
||||
wrap(Trigger, "create", ENTITY_KINDS.TRIGGER, "create", {
|
||||
after: async ({ result }) => {
|
||||
if (!result || !result.id) return null;
|
||||
let parentUuid = null;
|
||||
if (result.table_id) {
|
||||
const t = await lookupByCurrent(ENTITY_KINDS.TABLE, result.table_id);
|
||||
parentUuid = t ? t.uuid : null;
|
||||
}
|
||||
const name = result.name || `trigger_${result.id}`;
|
||||
const uuid = await assignNewUuid(ENTITY_KINDS.TRIGGER, result.id, name, parentUuid);
|
||||
return { entityUuid: uuid, payload: { after: snapshotInstance(result, TRIGGER_KEYS), parent_uuid: parentUuid } };
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger.update is static (id, row)
|
||||
wrap(Trigger, "update", ENTITY_KINDS.TRIGGER, "update", {
|
||||
before: async ({ args }) => {
|
||||
const id = args[0];
|
||||
const row = args[1] || {};
|
||||
const existing = await lookupByCurrent(ENTITY_KINDS.TRIGGER, id);
|
||||
return {
|
||||
id: id,
|
||||
uuid: existing ? existing.uuid : null,
|
||||
oldName: existing ? existing.current_name : null,
|
||||
patch: row
|
||||
};
|
||||
},
|
||||
after: async ({ before }) => {
|
||||
if (!before || !before.uuid) return null;
|
||||
const newName = before.patch.name !== undefined ? before.patch.name : before.oldName;
|
||||
if (newName && newName !== before.oldName) {
|
||||
await updateName(ENTITY_KINDS.TRIGGER, before.id, newName);
|
||||
}
|
||||
return { entityUuid: before.uuid, payload: { patch: before.patch } };
|
||||
}
|
||||
});
|
||||
|
||||
wrap(Trigger.prototype, "delete", ENTITY_KINDS.TRIGGER, "drop", standardDropHooks(ENTITY_KINDS.TRIGGER, TRIGGER_KEYS));
|
||||
};
|
||||
|
||||
|
||||
// ----- Role -----
|
||||
|
||||
const wrapRole = () => {
|
||||
wrap(Role, "create", ENTITY_KINDS.ROLE, "create", {
|
||||
after: async ({ args, result }) => {
|
||||
// Role.create returns inserted row; id is in result. Some return shapes vary.
|
||||
const id = result && result.id ? result.id : (args[0] && args[0].id);
|
||||
const role = result && result.role ? result.role : (args[0] && args[0].role);
|
||||
if (!id || !role) return null;
|
||||
const uuid = await assignNewUuid(ENTITY_KINDS.ROLE, id, role, null);
|
||||
return { entityUuid: uuid, payload: { after: { id: id, role: role } } };
|
||||
}
|
||||
});
|
||||
|
||||
wrap(Role.prototype, "update", ENTITY_KINDS.ROLE, "update", {
|
||||
before: async function () {
|
||||
const existing = await lookupByCurrent(ENTITY_KINDS.ROLE, this.id);
|
||||
return { uuid: existing ? existing.uuid : null, oldName: existing ? existing.current_name : this.role, snapshot: snapshotInstance(this, ROLE_KEYS) };
|
||||
},
|
||||
after: async function ({ args, before }) {
|
||||
if (!before || !before.uuid) return null;
|
||||
const patch = args[0] || {};
|
||||
const newName = patch.role !== undefined ? patch.role : before.oldName;
|
||||
if (newName !== before.oldName) {
|
||||
await updateName(ENTITY_KINDS.ROLE, this.id, newName);
|
||||
}
|
||||
return { entityUuid: before.uuid, payload: { before: before.snapshot, patch: patch } };
|
||||
}
|
||||
});
|
||||
|
||||
wrap(Role.prototype, "delete", ENTITY_KINDS.ROLE, "drop", standardDropHooks(ENTITY_KINDS.ROLE, ROLE_KEYS));
|
||||
};
|
||||
|
||||
|
||||
// ----- Library -----
|
||||
|
||||
const wrapLibrary = () => {
|
||||
wrap(Library, "create", ENTITY_KINDS.LIBRARY, "create", {
|
||||
after: async ({ args, result }) => {
|
||||
// Library.create returns void; we look up by name from args
|
||||
const cfg = args[0] || {};
|
||||
if (!cfg.name) return null;
|
||||
const Library = require("@saltcorn/data/models/library");
|
||||
const lib = await Library.findOne({ name: cfg.name });
|
||||
if (!lib) return null;
|
||||
const uuid = await assignNewUuid(ENTITY_KINDS.LIBRARY, lib.id, lib.name, null);
|
||||
return { entityUuid: uuid, payload: { after: snapshotInstance(lib, LIBRARY_KEYS) } };
|
||||
}
|
||||
});
|
||||
|
||||
wrap(Library.prototype, "update", ENTITY_KINDS.LIBRARY, "update", {
|
||||
before: async function () {
|
||||
const existing = await lookupByCurrent(ENTITY_KINDS.LIBRARY, this.id);
|
||||
return { uuid: existing ? existing.uuid : null, oldName: existing ? existing.current_name : this.name, snapshot: snapshotInstance(this, LIBRARY_KEYS) };
|
||||
},
|
||||
after: async function ({ args, before }) {
|
||||
if (!before || !before.uuid) return null;
|
||||
const patch = args[0] || {};
|
||||
const newName = patch.name !== undefined ? patch.name : before.oldName;
|
||||
if (newName !== before.oldName) {
|
||||
await updateName(ENTITY_KINDS.LIBRARY, this.id, newName);
|
||||
}
|
||||
return { entityUuid: before.uuid, payload: { before: before.snapshot, patch: patch } };
|
||||
}
|
||||
});
|
||||
|
||||
wrap(Library.prototype, "delete", ENTITY_KINDS.LIBRARY, "drop", standardDropHooks(ENTITY_KINDS.LIBRARY, LIBRARY_KEYS));
|
||||
};
|
||||
|
||||
|
||||
// ----- Tag -----
|
||||
|
||||
const wrapTag = () => {
|
||||
wrap(Tag, "create", ENTITY_KINDS.TAG, "create", {
|
||||
after: async ({ result }) => {
|
||||
if (!result || !result.id) return null;
|
||||
const uuid = await assignNewUuid(ENTITY_KINDS.TAG, result.id, result.name, null);
|
||||
return { entityUuid: uuid, payload: { after: snapshotInstance(result, TAG_KEYS) } };
|
||||
}
|
||||
});
|
||||
|
||||
wrap(Tag.prototype, "update", ENTITY_KINDS.TAG, "update", {
|
||||
before: async function () {
|
||||
const existing = await lookupByCurrent(ENTITY_KINDS.TAG, this.id);
|
||||
return { uuid: existing ? existing.uuid : null, oldName: existing ? existing.current_name : this.name, snapshot: snapshotInstance(this, TAG_KEYS) };
|
||||
},
|
||||
after: async function ({ args, before }) {
|
||||
if (!before || !before.uuid) return null;
|
||||
const patch = args[0] || {};
|
||||
const newName = patch.name !== undefined ? patch.name : before.oldName;
|
||||
if (newName !== before.oldName) {
|
||||
await updateName(ENTITY_KINDS.TAG, this.id, newName);
|
||||
}
|
||||
return { entityUuid: before.uuid, payload: { before: before.snapshot, patch: patch } };
|
||||
}
|
||||
});
|
||||
|
||||
wrap(Tag.prototype, "delete", ENTITY_KINDS.TAG, "drop", standardDropHooks(ENTITY_KINDS.TAG, TAG_KEYS));
|
||||
};
|
||||
|
||||
|
||||
// ----- File -----
|
||||
|
||||
const FILE_KEYS = ["filename", "location", "mime_super", "mime_sub", "size_kb", "min_role_read"];
|
||||
|
||||
const wrapFile = () => {
|
||||
wrap(File, "create", ENTITY_KINDS.FILE, "create", {
|
||||
after: async ({ result }) => {
|
||||
if (!result || !result.location) return null;
|
||||
const relPath = toRelativePath(File, result.location);
|
||||
const synthId = fileLocationToId(relPath);
|
||||
let contentHash = null;
|
||||
try {
|
||||
contentHash = await sha256File(result.location);
|
||||
} catch (e) {
|
||||
// best-effort; some create paths may set up metadata only
|
||||
}
|
||||
const uuid = await assignNewUuid(ENTITY_KINDS.FILE, synthId, relPath, null);
|
||||
return {
|
||||
entityUuid: uuid,
|
||||
payload: {
|
||||
after: {
|
||||
filename: result.filename,
|
||||
relative_path: relPath,
|
||||
mime_super: result.mime_super,
|
||||
mime_sub: result.mime_sub,
|
||||
size_kb: result.size_kb,
|
||||
min_role_read: result.min_role_read,
|
||||
content_hash: contentHash
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
wrap(File.prototype, "delete", ENTITY_KINDS.FILE, "drop", {
|
||||
before: async function () {
|
||||
const relPath = toRelativePath(File, this.location);
|
||||
const synthId = fileLocationToId(relPath);
|
||||
const existing = await lookupByCurrent(ENTITY_KINDS.FILE, synthId);
|
||||
return {
|
||||
uuid: existing ? existing.uuid : null,
|
||||
currentId: synthId,
|
||||
relPath: relPath,
|
||||
parentUuid: null,
|
||||
snapshot: snapshotInstance(this, FILE_KEYS)
|
||||
};
|
||||
},
|
||||
after: async function ({ before }) {
|
||||
if (!before || !before.uuid) return null;
|
||||
await removeEntityRow(ENTITY_KINDS.FILE, before.currentId);
|
||||
return {
|
||||
entityUuid: before.uuid,
|
||||
payload: { before: { ...before.snapshot, relative_path: before.relPath } }
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
// ----- TableConstraint -----
|
||||
|
||||
const wrapTableConstraint = () => {
|
||||
wrap(TableConstraint, "create", ENTITY_KINDS.CONSTRAINT, "create", {
|
||||
after: async ({ result }) => {
|
||||
if (!result || !result.id) return null;
|
||||
// Guard against double-firing (e.g. the wrap may be installed in
|
||||
// multiple module-resolution contexts when sc-exec uses createRequire).
|
||||
const already = await lookupByCurrent(ENTITY_KINDS.CONSTRAINT, result.id);
|
||||
if (already) return null;
|
||||
let parentUuid = null;
|
||||
if (result.table_id) {
|
||||
const t = await lookupByCurrent(ENTITY_KINDS.TABLE, result.table_id);
|
||||
parentUuid = t ? t.uuid : null;
|
||||
}
|
||||
const name = constraintDisplayName(result);
|
||||
const uuid = await assignNewUuid(ENTITY_KINDS.CONSTRAINT, result.id, name, parentUuid);
|
||||
return {
|
||||
entityUuid: uuid,
|
||||
payload: { after: snapshotInstance(result, CONSTRAINT_KEYS), parent_uuid: parentUuid }
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
wrap(TableConstraint.prototype, "delete", ENTITY_KINDS.CONSTRAINT, "drop", standardDropHooks(ENTITY_KINDS.CONSTRAINT, CONSTRAINT_KEYS));
|
||||
};
|
||||
|
||||
|
||||
// ----- Table row CRUD (managed/starter data_mode) -----
|
||||
//
|
||||
// Wraps Table.prototype.insertRow / updateRow / deleteRows. Each checks the
|
||||
// table's data_mode (looked up via _dd_table_modes). 'user' tables pass
|
||||
// through silently. 'managed' tables always journal. 'starter' tables journal
|
||||
// only during their initial ship (when starter_shipped_at is NULL); subsequent
|
||||
// row changes pass through.
|
||||
|
||||
const safeJournal = async (opType, entityUuid, payload) => {
|
||||
try {
|
||||
const opId = randomUuid();
|
||||
await enterOp(opId, async () => {
|
||||
await recordOpSafely({
|
||||
op_id: opId,
|
||||
op_type: opType,
|
||||
entity_kind: "table_row",
|
||||
entity_uuid: entityUuid,
|
||||
payload: payload
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[dev-deploy] ${opType} journal failed:`, e);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const wrapTableRows = () => {
|
||||
// --- insertRow ---
|
||||
const origInsert = Table.prototype.insertRow;
|
||||
if (origInsert && !origInsert.__ddWrapped) {
|
||||
const wrapped = async function (...args) {
|
||||
if (isSuppressed()) {
|
||||
return await origInsert.apply(this, args);
|
||||
}
|
||||
const decision = await journalDecision(this.id);
|
||||
if (!decision.shouldJournal) {
|
||||
return await origInsert.apply(this, args);
|
||||
}
|
||||
const v_in = args[0] || {};
|
||||
const newId = await origInsert.apply(this, args);
|
||||
if (!newId) return newId;
|
||||
|
||||
const rowUuid = newRowUuid();
|
||||
await setRowUuid(this.name, newId, rowUuid);
|
||||
|
||||
const { portable, warnings } = await rowToPortable({ ...v_in, id: newId }, this);
|
||||
await safeJournal("insert_row", rowUuid, {
|
||||
table_uuid: decision.tableUuid,
|
||||
after: portable,
|
||||
warnings: warnings.length > 0 ? warnings : undefined
|
||||
});
|
||||
return newId;
|
||||
};
|
||||
wrapped.__ddWrapped = true;
|
||||
wrapped.__ddOriginal = origInsert;
|
||||
Table.prototype.insertRow = wrapped;
|
||||
}
|
||||
|
||||
// --- updateRow ---
|
||||
const origUpdate = Table.prototype.updateRow;
|
||||
if (origUpdate && !origUpdate.__ddWrapped) {
|
||||
const wrapped = async function (v_in, id_in, ...rest) {
|
||||
if (isSuppressed()) {
|
||||
return await origUpdate.apply(this, [v_in, id_in, ...rest]);
|
||||
}
|
||||
const decision = await journalDecision(this.id);
|
||||
if (!decision.shouldJournal) {
|
||||
return await origUpdate.apply(this, [v_in, id_in, ...rest]);
|
||||
}
|
||||
const id = typeof id_in === "object" && id_in !== null ? id_in.id : id_in;
|
||||
const rowUuid = await getRowUuid(this.name, id);
|
||||
|
||||
const result = await origUpdate.apply(this, [v_in, id_in, ...rest]);
|
||||
|
||||
if (rowUuid) {
|
||||
const { portable } = await rowToPortable({ ...v_in, id: id }, this);
|
||||
await safeJournal("update_row", rowUuid, {
|
||||
table_uuid: decision.tableUuid,
|
||||
patch: portable
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
wrapped.__ddWrapped = true;
|
||||
wrapped.__ddOriginal = origUpdate;
|
||||
Table.prototype.updateRow = wrapped;
|
||||
}
|
||||
|
||||
// --- deleteRows ---
|
||||
const origDelete = Table.prototype.deleteRows;
|
||||
if (origDelete && !origDelete.__ddWrapped) {
|
||||
const wrapped = async function (where, ...rest) {
|
||||
if (isSuppressed()) {
|
||||
return await origDelete.apply(this, [where, ...rest]);
|
||||
}
|
||||
const decision = await journalDecision(this.id);
|
||||
if (!decision.shouldJournal) {
|
||||
return await origDelete.apply(this, [where, ...rest]);
|
||||
}
|
||||
// Capture rows BEFORE deletion so we can journal their uuids.
|
||||
const rows = await db.select(this.name, where || {});
|
||||
const result = await origDelete.apply(this, [where, ...rest]);
|
||||
for (const row of rows) {
|
||||
const rowUuid = row[ROW_UUID_COL];
|
||||
if (!rowUuid) continue;
|
||||
const { portable } = await rowToPortable(row, this);
|
||||
await safeJournal("drop_row", rowUuid, {
|
||||
table_uuid: decision.tableUuid,
|
||||
before: portable
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
wrapped.__ddWrapped = true;
|
||||
wrapped.__ddOriginal = origDelete;
|
||||
Table.prototype.deleteRows = wrapped;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// ----- Plugin configuration -----
|
||||
|
||||
// Wrap Plugin.prototype.upsert to journal configuration changes. Skip the
|
||||
// dev-deploy plugin itself (no self-reference). Skip cases where the upsert
|
||||
// doesn't change the configuration — Saltcorn calls upsert on every plugin
|
||||
// load, which would otherwise flood the journal.
|
||||
const wrapPlugin = () => {
|
||||
wrap(Plugin.prototype, "upsert", "plugin_config", "update", {
|
||||
before: async function () {
|
||||
if (!this.name || this.name === "dev-deploy") return null;
|
||||
let priorConfig = null;
|
||||
if (this.id) {
|
||||
const existing = await db.selectMaybeOne("_sc_plugins", { id: this.id });
|
||||
if (existing) {
|
||||
try {
|
||||
priorConfig = typeof existing.configuration === "string"
|
||||
? JSON.parse(existing.configuration)
|
||||
: (existing.configuration || null);
|
||||
} catch (e) {
|
||||
priorConfig = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { name: this.name, priorConfig: priorConfig };
|
||||
},
|
||||
after: async function ({ before }) {
|
||||
if (!before) return null;
|
||||
const newConfig = this.configuration || {};
|
||||
if (JSON.stringify(before.priorConfig || {}) === JSON.stringify(newConfig)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
entityUuid: null,
|
||||
payload: { name: before.name, configuration: newConfig }
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
// ----- Config keys (menu, etc.) -----
|
||||
|
||||
// Config keys we journal when state.setConfig is called. The op carries the
|
||||
// full key+value pair; apply replays it on the target via setConfig.
|
||||
// menu_items references pages/views by NAME (not id), which is naturally
|
||||
// stable across instances, so no UUID translation is needed.
|
||||
const TRACKED_CONFIG_KEYS = new Set([
|
||||
"menu_items",
|
||||
"site_name",
|
||||
"site_logo_id",
|
||||
"base_url"
|
||||
]);
|
||||
|
||||
|
||||
const wrapSetConfig = () => {
|
||||
const { getState } = require("@saltcorn/data/db/state");
|
||||
const state = getState();
|
||||
if (!state || typeof state.setConfig !== "function") return;
|
||||
if (state.setConfig.__ddWrapped) return;
|
||||
|
||||
const original = state.setConfig.bind(state);
|
||||
const wrapped = async function (key, value) {
|
||||
if (isSuppressed()) {
|
||||
return await original(key, value);
|
||||
}
|
||||
const result = await original(key, value);
|
||||
if (!TRACKED_CONFIG_KEYS.has(key)) {
|
||||
return result;
|
||||
}
|
||||
const opId = randomUuid();
|
||||
await enterOp(opId, async () => {
|
||||
await recordOpSafely({
|
||||
op_id: opId,
|
||||
op_type: "set_config",
|
||||
entity_kind: "config",
|
||||
entity_uuid: null, // config keys don't have UUIDs
|
||||
payload: { key: key, value: value }
|
||||
});
|
||||
});
|
||||
return result;
|
||||
};
|
||||
wrapped.__ddWrapped = true;
|
||||
wrapped.__ddOriginal = original;
|
||||
state.setConfig = wrapped;
|
||||
};
|
||||
|
||||
|
||||
// ----- PageGroup -----
|
||||
|
||||
// Members of a PageGroup reference page_id (integer). For cross-env identity
|
||||
// we translate page_id <-> page_uuid in payloads.
|
||||
const translateMembersToPortable = async (members) => {
|
||||
const out = [];
|
||||
for (const m of members || []) {
|
||||
const pageEnt = m.page_id ? await lookupByCurrent(ENTITY_KINDS.PAGE, m.page_id) : null;
|
||||
out.push({
|
||||
page_uuid: pageEnt ? pageEnt.uuid : null,
|
||||
eligible_formula: m.eligible_formula,
|
||||
description: m.description,
|
||||
sequence: m.sequence
|
||||
});
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
|
||||
const wrapPageGroup = () => {
|
||||
wrap(PageGroup, "create", ENTITY_KINDS.PAGE_GROUP, "create", {
|
||||
after: async ({ result }) => {
|
||||
if (!result || !result.id) return null;
|
||||
const already = await lookupByCurrent(ENTITY_KINDS.PAGE_GROUP, result.id);
|
||||
if (already) return null;
|
||||
const uuid = await assignNewUuid(ENTITY_KINDS.PAGE_GROUP, result.id, result.name, null);
|
||||
const members = await translateMembersToPortable(result.members || []);
|
||||
return {
|
||||
entityUuid: uuid,
|
||||
payload: { after: snapshotInstance(result, PAGE_GROUP_KEYS), members: members }
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
wrap(PageGroup, "update", ENTITY_KINDS.PAGE_GROUP, "update", {
|
||||
before: async ({ args }) => {
|
||||
const id = args[0];
|
||||
const row = args[1] || {};
|
||||
const existing = await lookupByCurrent(ENTITY_KINDS.PAGE_GROUP, id);
|
||||
return {
|
||||
id: id,
|
||||
uuid: existing ? existing.uuid : null,
|
||||
oldName: existing ? existing.current_name : null,
|
||||
patch: row
|
||||
};
|
||||
},
|
||||
after: async ({ before }) => {
|
||||
if (!before || !before.uuid) return null;
|
||||
const newName = before.patch.name !== undefined ? before.patch.name : before.oldName;
|
||||
if (newName && newName !== before.oldName) {
|
||||
await updateName(ENTITY_KINDS.PAGE_GROUP, before.id, newName);
|
||||
}
|
||||
return { entityUuid: before.uuid, payload: { patch: before.patch } };
|
||||
}
|
||||
});
|
||||
|
||||
wrap(PageGroup.prototype, "delete", ENTITY_KINDS.PAGE_GROUP, "drop", standardDropHooks(ENTITY_KINDS.PAGE_GROUP, PAGE_GROUP_KEYS));
|
||||
};
|
||||
|
||||
|
||||
// ----- PageGroupMember -----
|
||||
|
||||
const wrapPageGroupMember = () => {
|
||||
wrap(PageGroupMember, "create", ENTITY_KINDS.PAGE_GROUP_MEMBER, "create", {
|
||||
after: async ({ result }) => {
|
||||
if (!result || !result.id) return null;
|
||||
const already = await lookupByCurrent(ENTITY_KINDS.PAGE_GROUP_MEMBER, result.id);
|
||||
if (already) return null;
|
||||
let groupUuid = null;
|
||||
if (result.page_group_id) {
|
||||
const g = await lookupByCurrent(ENTITY_KINDS.PAGE_GROUP, result.page_group_id);
|
||||
groupUuid = g ? g.uuid : null;
|
||||
}
|
||||
let pageUuid = null;
|
||||
if (result.page_id) {
|
||||
const p = await lookupByCurrent(ENTITY_KINDS.PAGE, result.page_id);
|
||||
pageUuid = p ? p.uuid : null;
|
||||
}
|
||||
const uuid = await assignNewUuid(ENTITY_KINDS.PAGE_GROUP_MEMBER, result.id, `member_${result.id}`, groupUuid);
|
||||
return {
|
||||
entityUuid: uuid,
|
||||
payload: {
|
||||
after: {
|
||||
page_uuid: pageUuid,
|
||||
eligible_formula: result.eligible_formula,
|
||||
description: result.description,
|
||||
sequence: result.sequence
|
||||
},
|
||||
parent_uuid: groupUuid
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
wrap(PageGroupMember, "delete", ENTITY_KINDS.PAGE_GROUP_MEMBER, "drop", {
|
||||
before: async ({ args }) => {
|
||||
const id = args[0];
|
||||
const existing = await lookupByCurrent(ENTITY_KINDS.PAGE_GROUP_MEMBER, id);
|
||||
return {
|
||||
uuid: existing ? existing.uuid : null,
|
||||
currentId: id,
|
||||
parentUuid: existing ? existing.parent_uuid : null
|
||||
};
|
||||
},
|
||||
after: async ({ before }) => {
|
||||
if (!before || !before.uuid) return null;
|
||||
await removeEntityRow(ENTITY_KINDS.PAGE_GROUP_MEMBER, before.currentId);
|
||||
return {
|
||||
entityUuid: before.uuid,
|
||||
payload: { before: { id: before.currentId }, parent_uuid: before.parentUuid }
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
// ----- WorkflowStep -----
|
||||
|
||||
const wrapWorkflowStep = () => {
|
||||
wrap(WorkflowStep, "create", ENTITY_KINDS.WORKFLOW_STEP, "create", {
|
||||
after: async ({ args, result }) => {
|
||||
// WorkflowStep.create returns the inserted integer id (db.insert),
|
||||
// not an instance. Use the args + id to reconstruct the snapshot.
|
||||
const id = typeof result === "number" ? result : (result && result.id);
|
||||
if (!id) return null;
|
||||
const already = await lookupByCurrent(ENTITY_KINDS.WORKFLOW_STEP, id);
|
||||
if (already) return null;
|
||||
const step = await WorkflowStep.findOne({ id: id });
|
||||
if (!step) return null;
|
||||
let parentUuid = null;
|
||||
if (step.trigger_id) {
|
||||
const tr = await lookupByCurrent(ENTITY_KINDS.TRIGGER, step.trigger_id);
|
||||
parentUuid = tr ? tr.uuid : null;
|
||||
}
|
||||
const name = step.name || `step_${id}`;
|
||||
const uuid = await assignNewUuid(ENTITY_KINDS.WORKFLOW_STEP, id, name, parentUuid);
|
||||
return {
|
||||
entityUuid: uuid,
|
||||
payload: { after: snapshotInstance(step, WORKFLOW_STEP_KEYS), parent_uuid: parentUuid }
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
wrap(WorkflowStep.prototype, "update", ENTITY_KINDS.WORKFLOW_STEP, "update", {
|
||||
before: async function () {
|
||||
const existing = await lookupByCurrent(ENTITY_KINDS.WORKFLOW_STEP, this.id);
|
||||
return {
|
||||
uuid: existing ? existing.uuid : null,
|
||||
oldName: existing ? existing.current_name : this.name,
|
||||
snapshot: snapshotInstance(this, WORKFLOW_STEP_KEYS)
|
||||
};
|
||||
},
|
||||
after: async function ({ args, before }) {
|
||||
if (!before || !before.uuid) return null;
|
||||
const patch = args[0] || {};
|
||||
const newName = patch.name !== undefined ? patch.name : before.oldName;
|
||||
if (newName && newName !== before.oldName) {
|
||||
await updateName(ENTITY_KINDS.WORKFLOW_STEP, this.id, newName);
|
||||
}
|
||||
return { entityUuid: before.uuid, payload: { before: before.snapshot, patch: patch } };
|
||||
}
|
||||
});
|
||||
|
||||
wrap(WorkflowStep.prototype, "delete", ENTITY_KINDS.WORKFLOW_STEP, "drop", standardDropHooks(ENTITY_KINDS.WORKFLOW_STEP, WORKFLOW_STEP_KEYS));
|
||||
};
|
||||
|
||||
|
||||
const installAllWraps = () => {
|
||||
wrapTable();
|
||||
wrapField();
|
||||
wrapView();
|
||||
wrapPage();
|
||||
wrapTrigger();
|
||||
wrapRole();
|
||||
wrapLibrary();
|
||||
wrapTag();
|
||||
wrapTableConstraint();
|
||||
wrapFile();
|
||||
wrapPageGroup();
|
||||
wrapPageGroupMember();
|
||||
wrapWorkflowStep();
|
||||
wrapSetConfig();
|
||||
wrapPlugin();
|
||||
wrapTableRows();
|
||||
};
|
||||
|
||||
|
||||
module.exports = {
|
||||
installAllWraps
|
||||
};
|
||||
21
dev-deploy/package.json
Normal file
21
dev-deploy/package.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "dev-deploy",
|
||||
"version": "0.0.1",
|
||||
"description": "Saltcorn plugin: migrate metadata changes (tables, fields, views, pages, triggers, roles, library, tags) across Dev/Test/Prod environments via an ops journal with stable UUIDs and HMAC-authenticated peer endpoints. Detects concurrent-edit conflicts and offers theirs/mine resolution. User-table row data is never touched.",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "node test/e2e.js"
|
||||
},
|
||||
"keywords": [
|
||||
"deploy",
|
||||
"migration",
|
||||
"ops-journal",
|
||||
"hmac",
|
||||
"metadata-sync"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"author": "Scott Duensing",
|
||||
"license": "MIT"
|
||||
}
|
||||
980
dev-deploy/test/e2e.js
Normal file
980
dev-deploy/test/e2e.js
Normal file
|
|
@ -0,0 +1,980 @@
|
|||
// dev-deploy integration test suite.
|
||||
//
|
||||
// Prereqs: both saltcorn instances running and reachable, plugin installed +
|
||||
// bootstrapped, admin@local / AdminP@ss1 present on both. Run from the
|
||||
// dev-deploy/ directory:
|
||||
//
|
||||
// cd ~/claude/saltcorn && ./startServer.sh & ./startServerTest.sh &
|
||||
// node test/e2e.js
|
||||
//
|
||||
// Each test resets only what it needs to. Tests run in order; assertion
|
||||
// failures are caught and counted -- one failure does not stop the suite.
|
||||
|
||||
const { execFileSync, spawn } = require("node:child_process");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
|
||||
const MAIN = { url: "http://localhost:3000", db: "/home/scott/claude/saltcorn/.dev-state/saltcorn.sqlite", env: "/home/scott/claude/saltcorn/.dev-state/env.sh" };
|
||||
const TEST = { url: "http://localhost:3001", db: "/home/scott/claude/saltcorn/.dev-state-test/saltcorn.sqlite", env: "/home/scott/claude/saltcorn/.dev-state-test/env.sh" };
|
||||
|
||||
const MAIN_COOKIES = "/tmp/dd-test-jar-main.txt";
|
||||
const TEST_COOKIES = "/tmp/dd-test-jar-test.txt";
|
||||
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
const sql = (dbPath, query) => {
|
||||
const out = execFileSync("sqlite3", [dbPath, query], { encoding: "utf8" });
|
||||
return out.trim();
|
||||
};
|
||||
|
||||
|
||||
const sqlRows = (dbPath, query) => {
|
||||
const out = sql(dbPath, query);
|
||||
if (!out) return [];
|
||||
return out.split("\n");
|
||||
};
|
||||
|
||||
|
||||
const resetInstanceDb = (dbPath) => {
|
||||
sql(dbPath, `
|
||||
DELETE FROM _dd_ops;
|
||||
DELETE FROM _dd_peers;
|
||||
DELETE FROM _dd_anchors;
|
||||
DELETE FROM _dd_table_modes;
|
||||
DELETE FROM _sc_table_constraints;
|
||||
DELETE FROM _sc_workflow_steps;
|
||||
DELETE FROM _sc_page_group_members;
|
||||
DELETE FROM _sc_page_groups;
|
||||
DELETE FROM _sc_pages;
|
||||
DELETE FROM _sc_triggers;
|
||||
DELETE FROM _sc_views;
|
||||
DELETE FROM _sc_config WHERE key IN ('menu_items', 'unrolled_menu_items');
|
||||
UPDATE _sc_plugins SET configuration = NULL WHERE name != 'dev-deploy';
|
||||
`);
|
||||
// Strip _dd_entity_ids back to Saltcorn's bootstrap baseline.
|
||||
sql(dbPath, `
|
||||
DELETE FROM _dd_entity_ids WHERE kind NOT IN ('table','field','role');
|
||||
DELETE FROM _dd_entity_ids WHERE kind='table' AND current_name != 'users';
|
||||
DELETE FROM _dd_entity_ids WHERE kind='field' AND current_name NOT IN ('id','email','role_id');
|
||||
DELETE FROM _dd_entity_ids WHERE kind='role' AND current_name NOT IN ('admin','staff','user','public');
|
||||
`);
|
||||
const userTables = sqlRows(dbPath, "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE '\\_%' ESCAPE '\\' AND name != 'users' AND name NOT LIKE 'sqlite_%';");
|
||||
for (const t of userTables) {
|
||||
if (t) sql(dbPath, `DROP TABLE IF EXISTS "${t}"`);
|
||||
}
|
||||
sql(dbPath, "DELETE FROM _sc_tables WHERE name != 'users'; DELETE FROM _sc_fields WHERE table_id NOT IN (SELECT id FROM _sc_tables);");
|
||||
};
|
||||
|
||||
|
||||
const runJs = (envPath, code) => {
|
||||
return execFileSync(
|
||||
"bash",
|
||||
["-c", `source ${envPath} && saltcorn run-js --code ${JSON.stringify(code)}`],
|
||||
{ encoding: "utf8" }
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
// scExec is like runJs but uses our test/sc-exec.js shim, giving the JS body
|
||||
// full require() access -- needed for Field, TableConstraint, File, etc. that
|
||||
// saltcorn run-js's vm sandbox doesn't expose. Code is piped via stdin to
|
||||
// dodge shell-escape pitfalls with multi-line strings.
|
||||
const scExec = (envPath, code) => {
|
||||
return execFileSync(
|
||||
"bash",
|
||||
["-c", `source ${envPath} && node ${path.join(__dirname, "sc-exec.js")}`],
|
||||
{ encoding: "utf8", input: code }
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
// --- HTTP helpers (browser-style admin auth via cookie jar) ---
|
||||
|
||||
const curl = (args) => {
|
||||
const out = execFileSync("curl", args, { encoding: "utf8" });
|
||||
return out;
|
||||
};
|
||||
|
||||
|
||||
const extractCsrfFromHtml = (html) => {
|
||||
const m = html.match(/name="_csrf"[^>]*value="([^"]+)"/);
|
||||
return m ? m[1] : null;
|
||||
};
|
||||
|
||||
|
||||
const login = (instance, jar) => {
|
||||
execFileSync("rm", ["-f", jar]);
|
||||
const page = curl(["-sS", "-c", jar, `${instance.url}/auth/login`]);
|
||||
const csrf = extractCsrfFromHtml(page);
|
||||
if (!csrf) throw new Error(`no csrf on login page for ${instance.url}`);
|
||||
curl([
|
||||
"-sS", "-c", jar, "-b", jar, "-o", "/dev/null",
|
||||
"-X", "POST", `${instance.url}/auth/login`,
|
||||
"-d", `email=admin@local&password=AdminP@ss1&_csrf=${csrf}`
|
||||
]);
|
||||
};
|
||||
|
||||
|
||||
const envIdOf = (instance, jar) => {
|
||||
const html = curl(["-sS", "-b", jar, `${instance.url}/admin/dev-deploy/`]);
|
||||
const m = html.match(/<code>([0-9a-f-]{36})<\/code>/);
|
||||
if (!m) throw new Error(`no env_id in dashboard html for ${instance.url}`);
|
||||
return m[1];
|
||||
};
|
||||
|
||||
|
||||
const freshCsrf = (instance, jar, urlPath) => {
|
||||
const html = curl(["-sS", "-b", jar, `${instance.url}${urlPath}`]);
|
||||
return extractCsrfFromHtml(html);
|
||||
};
|
||||
|
||||
|
||||
// Each admin POST handler embeds CSRF in a form on a specific GET page.
|
||||
// Match the POST path to the page that renders its form.
|
||||
const CSRF_PAGE_FOR = [
|
||||
["/admin/dev-deploy/peers/add", "/admin/dev-deploy/peers"],
|
||||
["/admin/dev-deploy/peers/rotate", "/admin/dev-deploy/peers"],
|
||||
["/admin/dev-deploy/peers/delete", "/admin/dev-deploy/peers"],
|
||||
["/admin/dev-deploy/promote", "/admin/dev-deploy/peers"],
|
||||
["/admin/dev-deploy/pull", "/admin/dev-deploy/peers"],
|
||||
["/admin/dev-deploy/conflicts/resolve", "/admin/dev-deploy/conflicts"],
|
||||
["/admin/dev-deploy/tables/set", "/admin/dev-deploy/tables"],
|
||||
["/admin/dev-deploy/revert", "/admin/dev-deploy/ops"]
|
||||
];
|
||||
|
||||
|
||||
const adminPost = (instance, jar, urlPath, fields) => {
|
||||
const mapping = CSRF_PAGE_FOR.find(([p]) => urlPath === p);
|
||||
if (!mapping) throw new Error(`no CSRF source page mapped for POST ${urlPath}`);
|
||||
const csrf = freshCsrf(instance, jar, mapping[1]);
|
||||
if (!csrf) throw new Error(`no CSRF token on ${mapping[1]} for POST ${urlPath}`);
|
||||
const args = ["-sS", "-b", jar, "-o", "/tmp/dd-test-postbody.html",
|
||||
"-w", "%{http_code}|%{redirect_url}",
|
||||
"-X", "POST", `${instance.url}${urlPath}`,
|
||||
"--data-urlencode", `_csrf=${csrf}`];
|
||||
for (const [k, v] of Object.entries(fields)) {
|
||||
args.push("--data-urlencode", `${k}=${v}`);
|
||||
}
|
||||
const out = curl(args);
|
||||
const [code, redirect] = out.split("|");
|
||||
return { status: parseInt(code, 10), redirect: redirect, body: require("node:fs").readFileSync("/tmp/dd-test-postbody.html", "utf8") };
|
||||
};
|
||||
|
||||
|
||||
// --- test runner ---
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
const failures = [];
|
||||
|
||||
const test = async (name, fn) => {
|
||||
try {
|
||||
await fn();
|
||||
console.log(` PASS ${name}`);
|
||||
passed++;
|
||||
} catch (err) {
|
||||
console.log(` FAIL ${name}`);
|
||||
console.log(` ${err.message}`);
|
||||
failures.push({ name, err });
|
||||
failed++;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const section = (name) => {
|
||||
console.log(`\n[${name}]`);
|
||||
};
|
||||
|
||||
|
||||
// --- scenarios ---
|
||||
|
||||
const main = async () => {
|
||||
section("preconditions");
|
||||
await test("both servers respond to /auth/login", async () => {
|
||||
for (const inst of [MAIN, TEST]) {
|
||||
const out = curl(["-sS", "-o", "/dev/null", "-w", "%{http_code}", `${inst.url}/auth/login`]);
|
||||
assert.equal(out, "200", `${inst.url} returned ${out}`);
|
||||
}
|
||||
});
|
||||
|
||||
section("reset both instances");
|
||||
resetInstanceDb(MAIN.db);
|
||||
resetInstanceDb(TEST.db);
|
||||
login(MAIN, MAIN_COOKIES);
|
||||
login(TEST, TEST_COOKIES);
|
||||
const mainEnv = envIdOf(MAIN, MAIN_COOKIES);
|
||||
const testEnv = envIdOf(TEST, TEST_COOKIES);
|
||||
console.log(` main env_id: ${mainEnv}`);
|
||||
console.log(` test env_id: ${testEnv}`);
|
||||
|
||||
section("schema");
|
||||
await test("six _dd_* tables exist on main", async () => {
|
||||
const tables = sqlRows(MAIN.db, "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE '_dd_%' ORDER BY name").join(",");
|
||||
assert.equal(tables, "_dd_anchors,_dd_entity_ids,_dd_env,_dd_ops,_dd_peers,_dd_table_modes");
|
||||
});
|
||||
await test("_dd_ops has conflict_with_op_id column", async () => {
|
||||
const cols = sqlRows(MAIN.db, "PRAGMA table_info(_dd_ops)").map((r) => r.split("|")[1]);
|
||||
assert.ok(cols.includes("conflict_with_op_id"), `cols: ${cols.join(",")}`);
|
||||
});
|
||||
|
||||
section("bootstrap identity");
|
||||
await test("env_ids are distinct UUIDs", async () => {
|
||||
assert.notEqual(mainEnv, testEnv);
|
||||
assert.match(mainEnv, /^[0-9a-f-]{36}$/);
|
||||
});
|
||||
await test("'users' table has identical UUID on both instances (deterministic backfill)", async () => {
|
||||
const m = sql(MAIN.db, "SELECT uuid FROM _dd_entity_ids WHERE kind='table' AND current_name='users'");
|
||||
const t = sql(TEST.db, "SELECT uuid FROM _dd_entity_ids WHERE kind='table' AND current_name='users'");
|
||||
assert.equal(m, t, "deterministic UUIDs diverged");
|
||||
assert.notEqual(m, "");
|
||||
});
|
||||
await test("all baseline role UUIDs match across instances", async () => {
|
||||
const m = sqlRows(MAIN.db, "SELECT current_name||'|'||uuid FROM _dd_entity_ids WHERE kind='role' ORDER BY current_name").join(",");
|
||||
const t = sqlRows(TEST.db, "SELECT current_name||'|'||uuid FROM _dd_entity_ids WHERE kind='role' ORDER BY current_name").join(",");
|
||||
assert.equal(m, t);
|
||||
});
|
||||
|
||||
section("mutation capture");
|
||||
runJs(MAIN.env, 'const t = await Table.create("widgets"); await t.update({ description: "initial" });');
|
||||
await test("create_table op is journaled", async () => {
|
||||
const c = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE op_type='create_table' AND source_env_id='" + mainEnv + "'");
|
||||
assert.equal(c, "1");
|
||||
});
|
||||
await test("update_table op is journaled", async () => {
|
||||
const c = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE op_type='update_table' AND source_env_id='" + mainEnv + "'");
|
||||
assert.ok(parseInt(c, 10) >= 1);
|
||||
});
|
||||
|
||||
section("pairing");
|
||||
const addRes = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/peers/add", {
|
||||
env_id: testEnv,
|
||||
label: "test",
|
||||
base_url: TEST.url
|
||||
});
|
||||
await test("main /peers/add returns 200 with shared secret", async () => {
|
||||
assert.equal(addRes.status, 200);
|
||||
});
|
||||
const secretMatch = addRes.body.match(/class="secret">([0-9a-f]{64})</);
|
||||
if (!secretMatch) throw new Error("no shared secret in /peers/add response");
|
||||
const secret = secretMatch[1];
|
||||
|
||||
const pairTest = adminPost(TEST, TEST_COOKIES, "/admin/dev-deploy/peers/add", {
|
||||
env_id: mainEnv,
|
||||
label: "main",
|
||||
base_url: MAIN.url,
|
||||
existing_secret: secret
|
||||
});
|
||||
await test("test /peers/add accepts the shared secret", async () => {
|
||||
assert.equal(pairTest.status, 200);
|
||||
});
|
||||
|
||||
section("promote main -> test");
|
||||
const peerIdOnMain = sql(MAIN.db, `SELECT peer_id FROM _dd_peers WHERE env_id='${testEnv}'`);
|
||||
const promoteRes = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/promote", { peer_id: peerIdOnMain });
|
||||
await test("promote returns 302 with success message", async () => {
|
||||
assert.equal(promoteRes.status, 302);
|
||||
assert.match(promoteRes.redirect, /msg=promoted/);
|
||||
});
|
||||
await test("test instance now has widgets table", async () => {
|
||||
const r = sql(TEST.db, "SELECT name FROM _sc_tables WHERE name='widgets'");
|
||||
assert.equal(r, "widgets");
|
||||
});
|
||||
await test("widgets UUID matches between main and test", async () => {
|
||||
const m = sql(MAIN.db, "SELECT uuid FROM _dd_entity_ids WHERE current_name='widgets'");
|
||||
const t = sql(TEST.db, "SELECT uuid FROM _dd_entity_ids WHERE current_name='widgets'");
|
||||
assert.equal(m, t);
|
||||
});
|
||||
|
||||
section("pull test -> main (no conflicts)");
|
||||
runJs(TEST.env, 'await Table.create("test_added");');
|
||||
const peerIdOnMain2 = sql(MAIN.db, `SELECT peer_id FROM _dd_peers WHERE env_id='${testEnv}'`);
|
||||
const pullRes = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/pull", { peer_id: peerIdOnMain2 });
|
||||
await test("pull returns 302 with success message", async () => {
|
||||
assert.equal(pullRes.status, 302);
|
||||
assert.match(pullRes.redirect, /msg=pulled/);
|
||||
});
|
||||
await test("main now has test_added", async () => {
|
||||
const r = sql(MAIN.db, "SELECT name FROM _sc_tables WHERE name='test_added'");
|
||||
assert.equal(r, "test_added");
|
||||
});
|
||||
await test("test_added UUID matches across instances", async () => {
|
||||
const m = sql(MAIN.db, "SELECT uuid FROM _dd_entity_ids WHERE current_name='test_added'");
|
||||
const t = sql(TEST.db, "SELECT uuid FROM _dd_entity_ids WHERE current_name='test_added'");
|
||||
assert.equal(m, t);
|
||||
});
|
||||
|
||||
section("conflict detection + resolution");
|
||||
runJs(MAIN.env, 'const t = Table.findOne({name: "widgets"}); await t.update({ description: "from MAIN" });');
|
||||
runJs(TEST.env, 'const t = Table.findOne({name: "widgets"}); await t.update({ description: "from TEST" });');
|
||||
const peerIdForConflict = sql(MAIN.db, `SELECT peer_id FROM _dd_peers WHERE env_id='${testEnv}'`);
|
||||
const pullConflictRes = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/pull", { peer_id: peerIdForConflict });
|
||||
await test("pull with divergent edits returns 1 conflict", async () => {
|
||||
assert.match(pullConflictRes.redirect, /1%20conflicts/);
|
||||
});
|
||||
await test("conflict op recorded with status=conflict", async () => {
|
||||
const c = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE status='conflict'");
|
||||
assert.equal(c, "1");
|
||||
});
|
||||
await test("conflict op has conflict_with_op_id set", async () => {
|
||||
const r = sql(MAIN.db, "SELECT conflict_with_op_id FROM _dd_ops WHERE status='conflict'");
|
||||
assert.match(r, /^[0-9a-f-]{36}$/);
|
||||
});
|
||||
await test("widgets description on main is still 'from MAIN' (held op not applied)", async () => {
|
||||
const r = sql(MAIN.db, "SELECT description FROM _sc_tables WHERE name='widgets'");
|
||||
assert.equal(r, "from MAIN");
|
||||
});
|
||||
|
||||
const conflictOp = sql(MAIN.db, "SELECT op_id FROM _dd_ops WHERE status='conflict' LIMIT 1");
|
||||
const resolveRes = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/conflicts/resolve", {
|
||||
op_id: conflictOp,
|
||||
action: "theirs"
|
||||
});
|
||||
await test("resolve theirs returns 302 with success", async () => {
|
||||
assert.equal(resolveRes.status, 302);
|
||||
assert.match(resolveRes.redirect, /msg=resolved/);
|
||||
});
|
||||
await test("widgets description on main is now 'from TEST'", async () => {
|
||||
const r = sql(MAIN.db, "SELECT description FROM _sc_tables WHERE name='widgets'");
|
||||
assert.equal(r, "from TEST");
|
||||
});
|
||||
await test("resolved op status is committed", async () => {
|
||||
const r = sql(MAIN.db, `SELECT status FROM _dd_ops WHERE op_id='${conflictOp}'`);
|
||||
assert.equal(r, "committed");
|
||||
});
|
||||
await test("pending conflicts is 0", async () => {
|
||||
const c = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE status='conflict'");
|
||||
assert.equal(c, "0");
|
||||
});
|
||||
|
||||
section("conflict merge per-field");
|
||||
runJs(MAIN.env, 'const t = Table.findOne({name: "widgets"}); await t.update({ description: "M2" });');
|
||||
runJs(TEST.env, 'const t = Table.findOne({name: "widgets"}); await t.update({ description: "T2" });');
|
||||
const peerIdForMerge = sql(MAIN.db, `SELECT peer_id FROM _dd_peers WHERE env_id='${testEnv}'`);
|
||||
adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/pull", { peer_id: peerIdForMerge });
|
||||
|
||||
await test("a fresh conflict appears for the second divergence", async () => {
|
||||
const c = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE status='conflict'");
|
||||
assert.equal(c, "1");
|
||||
});
|
||||
|
||||
const mergeOp = sql(MAIN.db, "SELECT op_id FROM _dd_ops WHERE status='conflict' LIMIT 1");
|
||||
|
||||
await test("merge view returns 200 for an update-vs-update conflict", async () => {
|
||||
const out = curl(["-sS", "-b", MAIN_COOKIES, "-o", "/dev/null", "-w", "%{http_code}",
|
||||
`${MAIN.url}/admin/dev-deploy/conflicts/merge?op_id=${mergeOp}`]);
|
||||
assert.equal(out, "200");
|
||||
});
|
||||
|
||||
await test("merge view shows the diverging description field", async () => {
|
||||
const html = curl(["-sS", "-b", MAIN_COOKIES,
|
||||
`${MAIN.url}/admin/dev-deploy/conflicts/merge?op_id=${mergeOp}`]);
|
||||
assert.match(html, /choice_description/);
|
||||
assert.match(html, /T2/);
|
||||
});
|
||||
|
||||
// Fetch the merge page for CSRF then POST a custom value
|
||||
const mergeHtml = curl(["-sS", "-b", MAIN_COOKIES,
|
||||
`${MAIN.url}/admin/dev-deploy/conflicts/merge?op_id=${mergeOp}`]);
|
||||
const mergeCsrf = extractCsrfFromHtml(mergeHtml);
|
||||
const mergeResp = execFileSync("curl", [
|
||||
"-sS", "-b", MAIN_COOKIES, "-o", "/dev/null",
|
||||
"-w", "%{http_code}|%{redirect_url}",
|
||||
"-X", "POST", `${MAIN.url}/admin/dev-deploy/conflicts/merge/apply`,
|
||||
"--data-urlencode", `_csrf=${mergeCsrf}`,
|
||||
"--data-urlencode", `op_id=${mergeOp}`,
|
||||
"--data-urlencode", "choice_description=custom",
|
||||
"--data-urlencode", "custom_description=hybrid value"
|
||||
], { encoding: "utf8" });
|
||||
const [mergeStatus, mergeRedirect] = mergeResp.split("|");
|
||||
|
||||
await test("merge apply returns 302 with success message", async () => {
|
||||
assert.equal(mergeStatus, "302");
|
||||
assert.match(mergeRedirect, /msg=merged/);
|
||||
});
|
||||
|
||||
await test("widgets description on main is now 'hybrid value'", async () => {
|
||||
const r = sql(MAIN.db, "SELECT description FROM _sc_tables WHERE name='widgets'");
|
||||
assert.equal(r, "hybrid value");
|
||||
});
|
||||
|
||||
await test("merged op has status=merged, conflict_with cleared", async () => {
|
||||
const r = sql(MAIN.db, `SELECT status||'|'||COALESCE(conflict_with_op_id,'') FROM _dd_ops WHERE op_id='${mergeOp}'`);
|
||||
assert.equal(r, "merged|");
|
||||
});
|
||||
|
||||
await test("pending conflicts is 0 after merge", async () => {
|
||||
const c = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE status='conflict'");
|
||||
assert.equal(c, "0");
|
||||
});
|
||||
|
||||
section("table constraints");
|
||||
// Add a field to widgets and a unique constraint on it. Promote to test.
|
||||
scExec(MAIN.env, `
|
||||
const t = Table.findOne({name: "widgets"});
|
||||
await Field.create({ table_id: t.id, name: "sku", label: "SKU", type: "String" });
|
||||
const fresh = Table.findOne({name: "widgets"});
|
||||
await TableConstraint.create({ table_id: fresh.id, type: "Unique", configuration: { fields: ["sku"] } });
|
||||
`);
|
||||
|
||||
await test("create_field op journaled for sku", async () => {
|
||||
const c = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE op_type='create_field' AND payload LIKE '%sku%'");
|
||||
assert.equal(c, "1");
|
||||
});
|
||||
await test("create_constraint op journaled with type=Unique", async () => {
|
||||
const c = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE op_type='create_constraint' AND payload LIKE '%Unique%'");
|
||||
assert.equal(c, "1");
|
||||
});
|
||||
|
||||
const peerIdForConstraint = sql(MAIN.db, `SELECT peer_id FROM _dd_peers WHERE env_id='${testEnv}'`);
|
||||
const promoteConstraintRes = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/promote", { peer_id: peerIdForConstraint });
|
||||
await test("promote with constraint returns success", async () => {
|
||||
assert.equal(promoteConstraintRes.status, 302);
|
||||
assert.match(promoteConstraintRes.redirect, /msg=promoted/);
|
||||
});
|
||||
await test("test now has the sku field", async () => {
|
||||
const r = sql(TEST.db, "SELECT COUNT(*) FROM _sc_fields WHERE name='sku' AND table_id=(SELECT id FROM _sc_tables WHERE name='widgets')");
|
||||
assert.equal(r, "1");
|
||||
});
|
||||
await test("test now has the unique constraint on sku", async () => {
|
||||
// _sc_table_constraints uses JSON for configuration; SQLite stores TEXT
|
||||
const r = sql(TEST.db, "SELECT COUNT(*) FROM _sc_table_constraints WHERE type='Unique' AND configuration LIKE '%sku%'");
|
||||
assert.equal(r, "1");
|
||||
});
|
||||
await test("constraint UUID matches across instances", async () => {
|
||||
const m = sql(MAIN.db, "SELECT uuid FROM _dd_entity_ids WHERE kind='constraint' AND current_name='unique(sku)'");
|
||||
const t = sql(TEST.db, "SELECT uuid FROM _dd_entity_ids WHERE kind='constraint' AND current_name='unique(sku)'");
|
||||
assert.equal(m, t);
|
||||
assert.notEqual(m, "");
|
||||
});
|
||||
|
||||
section("file propagation");
|
||||
const FILE_BYTES = "Hello, dev-deploy! This is a test document.";
|
||||
scExec(MAIN.env, `
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const tenant = db.getTenantSchema();
|
||||
const absPath = path.join(db.connectObj.file_store, tenant, "test_doc.txt");
|
||||
fs.mkdirSync(path.dirname(absPath), { recursive: true });
|
||||
fs.writeFileSync(absPath, ${JSON.stringify(FILE_BYTES)});
|
||||
await File.create({
|
||||
filename: "test_doc.txt",
|
||||
location: absPath,
|
||||
uploaded_at: new Date(),
|
||||
size_kb: 1,
|
||||
mime_super: "text",
|
||||
mime_sub: "plain",
|
||||
min_role_read: 1
|
||||
});
|
||||
`);
|
||||
|
||||
await test("create_file op journaled with content_hash + relative_path", async () => {
|
||||
const r = sql(MAIN.db, "SELECT payload FROM _dd_ops WHERE op_type='create_file' LIMIT 1");
|
||||
assert.match(r, /content_hash/);
|
||||
assert.match(r, /test_doc\.txt/);
|
||||
});
|
||||
|
||||
await test("file entry in _dd_entity_ids on main", async () => {
|
||||
const r = sql(MAIN.db, "SELECT current_name FROM _dd_entity_ids WHERE kind='file' LIMIT 1");
|
||||
assert.equal(r, "test_doc.txt");
|
||||
});
|
||||
|
||||
const peerIdForFile = sql(MAIN.db, `SELECT peer_id FROM _dd_peers WHERE env_id='${testEnv}'`);
|
||||
const fileRes = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/promote", { peer_id: peerIdForFile });
|
||||
|
||||
await test("file promote returns success", async () => {
|
||||
assert.equal(fileRes.status, 302);
|
||||
assert.match(fileRes.redirect, /msg=promoted/);
|
||||
});
|
||||
|
||||
await test("file exists on test instance with same bytes", async () => {
|
||||
const fs = require("node:fs");
|
||||
const out = fs.readFileSync("/home/scott/claude/saltcorn/.dev-state-test/files/public/test_doc.txt", "utf8");
|
||||
assert.equal(out, FILE_BYTES);
|
||||
});
|
||||
|
||||
await test("file UUID matches across instances", async () => {
|
||||
const m = sql(MAIN.db, "SELECT uuid FROM _dd_entity_ids WHERE kind='file' AND current_name='test_doc.txt'");
|
||||
const t = sql(TEST.db, "SELECT uuid FROM _dd_entity_ids WHERE kind='file' AND current_name='test_doc.txt'");
|
||||
assert.equal(m, t);
|
||||
assert.notEqual(m, "");
|
||||
});
|
||||
|
||||
section("plugin mismatch warning");
|
||||
await test("health endpoint is reachable and HMAC-required", async () => {
|
||||
const out = curl(["-sS", "-o", "/dev/null", "-w", "%{http_code}", `${TEST.url}/dev-deploy/api/health`]);
|
||||
assert.equal(out, "400"); // missing HMAC headers
|
||||
});
|
||||
await test("promote includes no plugin warnings when peers match", async () => {
|
||||
// Both instances have identical plugin lists (base, sbadmin2, dev-deploy)
|
||||
// so the warning suffix should be absent.
|
||||
runJs(MAIN.env, 'await Table.create("warning_demo_table");');
|
||||
const peerId = sql(MAIN.db, `SELECT peer_id FROM _dd_peers WHERE env_id='${testEnv}'`);
|
||||
const r = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/promote", { peer_id: peerId });
|
||||
assert.equal(r.status, 302);
|
||||
// Both sides have the same plugins so no WARNINGS suffix should appear.
|
||||
assert.ok(!/WARNINGS/.test(r.redirect || ""), `unexpected warnings: ${r.redirect}`);
|
||||
});
|
||||
|
||||
section("file refs in page layout");
|
||||
scExec(MAIN.env, `
|
||||
const Page = require("@saltcorn/data/models/page");
|
||||
// Pages with a file ref in their layout — fileid as a relative-path
|
||||
// string (the form Saltcorn produces on a fresh upload).
|
||||
await Page.create({
|
||||
name: "page_with_image",
|
||||
title: "Page With Image",
|
||||
description: "Has an image",
|
||||
min_role: 100,
|
||||
layout: { type: "image", srctype: "File", fileid: "test_doc.txt", alt: "test" },
|
||||
fixed_states: {}
|
||||
});
|
||||
`);
|
||||
|
||||
await test("create_page op replaces fileid with placeholder in journal", async () => {
|
||||
const r = sql(MAIN.db, "SELECT payload FROM _dd_ops WHERE op_type='create_page' AND payload LIKE '%page_with_image%' LIMIT 1");
|
||||
assert.match(r, /__dd_file_ref::/);
|
||||
// The original string must NOT appear as the raw fileid value
|
||||
assert.ok(!/"fileid":"test_doc\.txt"/.test(r), `fileid was not translated: ${r}`);
|
||||
});
|
||||
|
||||
const peerIdForRefs = sql(MAIN.db, `SELECT peer_id FROM _dd_peers WHERE env_id='${testEnv}'`);
|
||||
const refsPromote = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/promote", { peer_id: peerIdForRefs });
|
||||
await test("page-with-image promote returns success", async () => {
|
||||
assert.equal(refsPromote.status, 302);
|
||||
assert.match(refsPromote.redirect, /msg=promoted/);
|
||||
});
|
||||
|
||||
await test("page exists on test with fileid resolved back to local path", async () => {
|
||||
const layout = sql(TEST.db, "SELECT layout FROM _sc_pages WHERE name='page_with_image'");
|
||||
// On test, fileid should be the relative path that resolves locally
|
||||
assert.match(layout, /"fileid":"test_doc\.txt"/);
|
||||
// Placeholder must NOT leak into the live entity
|
||||
assert.ok(!/__dd_file_ref::/.test(layout), `placeholder leaked into live page: ${layout}`);
|
||||
});
|
||||
|
||||
section("page_group propagation");
|
||||
scExec(MAIN.env, `
|
||||
const Page = require("@saltcorn/data/models/page");
|
||||
const PageGroup = require("@saltcorn/data/models/page_group");
|
||||
const page = await Page.create({
|
||||
name: "intro_page",
|
||||
title: "Intro",
|
||||
description: "Intro page",
|
||||
min_role: 100,
|
||||
layout: { type: "blank", contents: "Hello" },
|
||||
fixed_states: {}
|
||||
});
|
||||
const pg = await PageGroup.create({
|
||||
name: "home_group",
|
||||
description: "home routing",
|
||||
min_role: 100,
|
||||
random_allocation: false,
|
||||
members: []
|
||||
});
|
||||
await pg.addMember({
|
||||
page_id: page.id,
|
||||
eligible_formula: "true",
|
||||
description: "default"
|
||||
});
|
||||
`);
|
||||
|
||||
await test("create_page_group op journaled", async () => {
|
||||
const r = sql(MAIN.db, "SELECT payload FROM _dd_ops WHERE op_type='create_page_group' LIMIT 1");
|
||||
assert.match(r, /home_group/);
|
||||
});
|
||||
await test("create_page_group_member op journaled with page_uuid", async () => {
|
||||
const r = sql(MAIN.db, "SELECT payload FROM _dd_ops WHERE op_type='create_page_group_member' LIMIT 1");
|
||||
assert.match(r, /page_uuid/);
|
||||
});
|
||||
|
||||
const peerIdForPG = sql(MAIN.db, `SELECT peer_id FROM _dd_peers WHERE env_id='${testEnv}'`);
|
||||
const pgPromote = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/promote", { peer_id: peerIdForPG });
|
||||
await test("page_group promote returns success", async () => {
|
||||
assert.equal(pgPromote.status, 302);
|
||||
assert.match(pgPromote.redirect, /msg=promoted/);
|
||||
});
|
||||
await test("test instance has home_group page_group", async () => {
|
||||
const r = sql(TEST.db, "SELECT name FROM _sc_page_groups WHERE name='home_group'");
|
||||
assert.equal(r, "home_group");
|
||||
});
|
||||
await test("page_group on test has its member row", async () => {
|
||||
const r = sql(TEST.db, "SELECT COUNT(*) FROM _sc_page_group_members m JOIN _sc_page_groups g ON m.page_group_id=g.id WHERE g.name='home_group'");
|
||||
assert.equal(r, "1");
|
||||
});
|
||||
await test("page_group UUID matches across instances", async () => {
|
||||
const m = sql(MAIN.db, "SELECT uuid FROM _dd_entity_ids WHERE kind='page_group' AND current_name='home_group'");
|
||||
const t = sql(TEST.db, "SELECT uuid FROM _dd_entity_ids WHERE kind='page_group' AND current_name='home_group'");
|
||||
assert.equal(m, t);
|
||||
assert.notEqual(m, "");
|
||||
});
|
||||
|
||||
section("workflow_step propagation");
|
||||
scExec(MAIN.env, `
|
||||
const Trigger = require("@saltcorn/data/models/trigger");
|
||||
const WorkflowStep = require("@saltcorn/data/models/workflow_step");
|
||||
// clean any prior
|
||||
const oldT = Trigger.findOne({name: "demo_workflow"});
|
||||
if (oldT) await oldT.delete();
|
||||
const tr = await Trigger.create({
|
||||
name: "demo_workflow",
|
||||
action: "Workflow",
|
||||
when_trigger: "Never",
|
||||
min_role: 1,
|
||||
configuration: {}
|
||||
});
|
||||
await WorkflowStep.create({
|
||||
name: "step1",
|
||||
trigger_id: tr.id,
|
||||
action_name: "set_context",
|
||||
next_step: "",
|
||||
initial_step: true,
|
||||
configuration: { ctx_values: '{ "hello": "world" }' },
|
||||
only_if: ""
|
||||
});
|
||||
`);
|
||||
|
||||
await test("create_workflow_step op journaled", async () => {
|
||||
const r = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE op_type='create_workflow_step'");
|
||||
assert.equal(r, "1");
|
||||
});
|
||||
|
||||
const peerIdForWf = sql(MAIN.db, `SELECT peer_id FROM _dd_peers WHERE env_id='${testEnv}'`);
|
||||
const wfPromote = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/promote", { peer_id: peerIdForWf });
|
||||
await test("workflow_step promote returns success", async () => {
|
||||
assert.equal(wfPromote.status, 302);
|
||||
assert.match(wfPromote.redirect, /msg=promoted/);
|
||||
});
|
||||
await test("test has the demo_workflow trigger", async () => {
|
||||
const r = sql(TEST.db, "SELECT name FROM _sc_triggers WHERE name='demo_workflow'");
|
||||
assert.equal(r, "demo_workflow");
|
||||
});
|
||||
await test("test has the workflow step", async () => {
|
||||
const r = sql(TEST.db, "SELECT COUNT(*) FROM _sc_workflow_steps s JOIN _sc_triggers t ON s.trigger_id=t.id WHERE t.name='demo_workflow' AND s.name='step1'");
|
||||
assert.equal(r, "1");
|
||||
});
|
||||
await test("workflow_step UUID matches across instances", async () => {
|
||||
const m = sql(MAIN.db, "SELECT uuid FROM _dd_entity_ids WHERE kind='workflow_step' AND current_name='step1'");
|
||||
const t = sql(TEST.db, "SELECT uuid FROM _dd_entity_ids WHERE kind='workflow_step' AND current_name='step1'");
|
||||
assert.equal(m, t);
|
||||
assert.notEqual(m, "");
|
||||
});
|
||||
|
||||
section("config + menu propagation");
|
||||
scExec(MAIN.env, `
|
||||
const { getState } = require("@saltcorn/data/db/state");
|
||||
await getState().setConfig("menu_items", [
|
||||
{ label: "Widgets", type: "View", viewname: "widget_list" },
|
||||
{ label: "About", type: "Page", pagename: "about" }
|
||||
]);
|
||||
`);
|
||||
await test("set_config op journaled for menu_items", async () => {
|
||||
const r = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE op_type='set_config' AND payload LIKE '%menu_items%'");
|
||||
assert.ok(parseInt(r, 10) >= 1, `got ${r} set_config ops`);
|
||||
});
|
||||
const peerIdForMenu = sql(MAIN.db, `SELECT peer_id FROM _dd_peers WHERE env_id='${testEnv}'`);
|
||||
const menuPromote = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/promote", { peer_id: peerIdForMenu });
|
||||
await test("menu promote returns success", async () => {
|
||||
assert.equal(menuPromote.status, 302);
|
||||
assert.match(menuPromote.redirect, /msg=promoted/);
|
||||
});
|
||||
await test("test instance has matching menu_items", async () => {
|
||||
const v = sql(TEST.db, "SELECT value FROM _sc_config WHERE key='menu_items'");
|
||||
assert.match(v, /widget_list/);
|
||||
assert.match(v, /about/);
|
||||
});
|
||||
|
||||
section("plugin configuration propagation");
|
||||
scExec(MAIN.env, `
|
||||
const Plugin = require("@saltcorn/data/models/plugin");
|
||||
const p = await Plugin.findOne({ name: "sbadmin2" });
|
||||
if (!p) throw new Error("sbadmin2 plugin not found");
|
||||
p.configuration = { test_key: "from MAIN", color_scheme: "dark" };
|
||||
await p.upsert();
|
||||
`);
|
||||
await test("update_plugin_config op journaled for sbadmin2", async () => {
|
||||
const r = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE op_type='update_plugin_config' AND payload LIKE '%sbadmin2%'");
|
||||
assert.equal(r, "1");
|
||||
});
|
||||
const peerIdForPC = sql(MAIN.db, `SELECT peer_id FROM _dd_peers WHERE env_id='${testEnv}'`);
|
||||
const pcPromote = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/promote", { peer_id: peerIdForPC });
|
||||
await test("plugin-config promote returns success", async () => {
|
||||
assert.equal(pcPromote.status, 302);
|
||||
assert.match(pcPromote.redirect, /msg=promoted/);
|
||||
});
|
||||
await test("test sbadmin2 has updated configuration", async () => {
|
||||
const cfg = sql(TEST.db, "SELECT configuration FROM _sc_plugins WHERE name='sbadmin2'");
|
||||
assert.match(cfg, /from MAIN/);
|
||||
assert.match(cfg, /color_scheme/);
|
||||
});
|
||||
|
||||
section("managed row data propagation");
|
||||
|
||||
// Create two tables on main: categories + products with FK products.category_id -> categories.id.
|
||||
// Add some rows. Then mark both managed. Promote. Verify rows on test with same UUIDs.
|
||||
scExec(MAIN.env, `
|
||||
const Table = require("@saltcorn/data/models/table");
|
||||
const Field = require("@saltcorn/data/models/field");
|
||||
|
||||
const cats = await Table.create("dd_categories");
|
||||
await Field.create({ table_id: cats.id, name: "name", label: "Name", type: "String" });
|
||||
|
||||
const prods = await Table.create("dd_products");
|
||||
await Field.create({ table_id: prods.id, name: "name", label: "Name", type: "String" });
|
||||
await Field.create({ table_id: prods.id, name: "price", label: "Price", type: "Float" });
|
||||
await Field.create({ table_id: prods.id, name: "category", label: "Category", type: "Key to dd_categories", reftable_name: "dd_categories", reftype: "Integer" });
|
||||
|
||||
const cats2 = Table.findOne({name: "dd_categories"});
|
||||
const electId = await cats2.insertRow({ name: "Electronics" });
|
||||
const apparelId = await cats2.insertRow({ name: "Apparel" });
|
||||
|
||||
const prods2 = Table.findOne({name: "dd_products"});
|
||||
await prods2.insertRow({ name: "Widget", price: 9.99, category: electId });
|
||||
await prods2.insertRow({ name: "Gadget", price: 19.95, category: electId });
|
||||
await prods2.insertRow({ name: "T-shirt", price: 14.99, category: apparelId });
|
||||
`);
|
||||
|
||||
// Mark both tables managed via the admin UI (causes initial ship)
|
||||
const catsUuid = sql(MAIN.db, "SELECT uuid FROM _dd_entity_ids WHERE kind='table' AND current_name='dd_categories'");
|
||||
const prodsUuid = sql(MAIN.db, "SELECT uuid FROM _dd_entity_ids WHERE kind='table' AND current_name='dd_products'");
|
||||
|
||||
const setCatsManaged = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/tables/set", { table_uuid: catsUuid, data_mode: "managed" });
|
||||
const setProdsManaged = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/tables/set", { table_uuid: prodsUuid, data_mode: "managed" });
|
||||
|
||||
await test("marking dd_categories managed ships 2 rows", async () => {
|
||||
assert.match(setCatsManaged.redirect, /shipped.{1,3}2.{1,3}rows/);
|
||||
});
|
||||
await test("marking dd_products managed ships 3 rows", async () => {
|
||||
assert.match(setProdsManaged.redirect, /shipped.{1,3}3.{1,3}rows/);
|
||||
});
|
||||
await test("_dd_row_uuid column exists on dd_categories on main", async () => {
|
||||
const info = sqlRows(MAIN.db, "PRAGMA table_info(dd_categories);").join(",");
|
||||
assert.match(info, /_dd_row_uuid/);
|
||||
});
|
||||
await test("insert_row ops journaled for the seed rows", async () => {
|
||||
const c = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE op_type='insert_row'");
|
||||
assert.ok(parseInt(c, 10) >= 5, `expected >=5 insert_row ops, got ${c}`);
|
||||
});
|
||||
|
||||
// Promote
|
||||
const peerIdForRows = sql(MAIN.db, `SELECT peer_id FROM _dd_peers WHERE env_id='${testEnv}'`);
|
||||
const rowPromote = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/promote", { peer_id: peerIdForRows });
|
||||
await test("managed row promote returns success", async () => {
|
||||
assert.equal(rowPromote.status, 302);
|
||||
assert.match(rowPromote.redirect, /msg=promoted/);
|
||||
});
|
||||
await test("test has dd_categories rows", async () => {
|
||||
const r = sql(TEST.db, "SELECT COUNT(*) FROM dd_categories");
|
||||
assert.equal(r, "2");
|
||||
});
|
||||
await test("test has dd_products rows", async () => {
|
||||
const r = sql(TEST.db, "SELECT COUNT(*) FROM dd_products");
|
||||
assert.equal(r, "3");
|
||||
});
|
||||
await test("a product on test has its FK resolved to a local category id", async () => {
|
||||
const r = sql(TEST.db, "SELECT p.name || '|' || c.name FROM dd_products p JOIN dd_categories c ON p.category=c.id WHERE p.name='Widget'");
|
||||
assert.equal(r, "Widget|Electronics");
|
||||
});
|
||||
await test("row UUIDs match across instances for a product", async () => {
|
||||
const m = sql(MAIN.db, "SELECT _dd_row_uuid FROM dd_products WHERE name='Widget'");
|
||||
const t = sql(TEST.db, "SELECT _dd_row_uuid FROM dd_products WHERE name='Widget'");
|
||||
assert.equal(m, t);
|
||||
assert.notEqual(m, "");
|
||||
});
|
||||
|
||||
// Update a row on main, promote again, verify on test.
|
||||
scExec(MAIN.env, `
|
||||
const Table = require("@saltcorn/data/models/table");
|
||||
const prods = Table.findOne({name: "dd_products"});
|
||||
const widget = (await prods.getJoinedRows({ where: { name: "Widget" }}))[0];
|
||||
await prods.updateRow({ price: 12.50 }, widget.id);
|
||||
`);
|
||||
const rowPromote2 = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/promote", { peer_id: peerIdForRows });
|
||||
await test("update_row op promoted", async () => {
|
||||
assert.match(rowPromote2.redirect, /msg=promoted/);
|
||||
});
|
||||
await test("test sees Widget at new price 12.5", async () => {
|
||||
const r = sql(TEST.db, "SELECT price FROM dd_products WHERE name='Widget'");
|
||||
assert.equal(r, "12.5");
|
||||
});
|
||||
|
||||
// Delete a row on main, promote, verify removed on test.
|
||||
scExec(MAIN.env, `
|
||||
const Table = require("@saltcorn/data/models/table");
|
||||
const prods = Table.findOne({name: "dd_products"});
|
||||
await prods.deleteRows({ name: "T-shirt" });
|
||||
`);
|
||||
const rowPromote3 = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/promote", { peer_id: peerIdForRows });
|
||||
await test("drop_row op promoted", async () => {
|
||||
assert.match(rowPromote3.redirect, /msg=promoted/);
|
||||
});
|
||||
await test("test no longer has T-shirt row", async () => {
|
||||
const r = sql(TEST.db, "SELECT COUNT(*) FROM dd_products WHERE name='T-shirt'");
|
||||
assert.equal(r, "0");
|
||||
});
|
||||
|
||||
section("starter rows: ship once then detach");
|
||||
scExec(MAIN.env, `
|
||||
const Table = require("@saltcorn/data/models/table");
|
||||
const Field = require("@saltcorn/data/models/field");
|
||||
const seed = await Table.create("dd_templates");
|
||||
await Field.create({ table_id: seed.id, name: "name", label: "Name", type: "String" });
|
||||
const t = Table.findOne({name: "dd_templates"});
|
||||
await t.insertRow({ name: "Welcome" });
|
||||
await t.insertRow({ name: "Goodbye" });
|
||||
`);
|
||||
const seedUuid = sql(MAIN.db, "SELECT uuid FROM _dd_entity_ids WHERE kind='table' AND current_name='dd_templates'");
|
||||
const setSeedStarter = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/tables/set", { table_uuid: seedUuid, data_mode: "starter" });
|
||||
await test("marking dd_templates starter ships 2 rows", async () => {
|
||||
assert.match(setSeedStarter.redirect, /shipped.{1,3}2.{1,3}rows/);
|
||||
});
|
||||
await test("dd_templates marked starter_shipped_at", async () => {
|
||||
const r = sql(MAIN.db, `SELECT starter_shipped_at IS NOT NULL FROM _dd_table_modes WHERE table_uuid='${seedUuid}'`);
|
||||
assert.equal(r, "1");
|
||||
});
|
||||
|
||||
// After starter ship, further row ops on main should NOT journal
|
||||
const beforeCount = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE op_type='insert_row' OR op_type='update_row'");
|
||||
scExec(MAIN.env, `
|
||||
const Table = require("@saltcorn/data/models/table");
|
||||
const t = Table.findOne({name: "dd_templates"});
|
||||
await t.insertRow({ name: "Post-ship row" });
|
||||
`);
|
||||
const afterCount = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE op_type='insert_row' OR op_type='update_row'");
|
||||
await test("subsequent inserts on starter table do NOT journal", async () => {
|
||||
assert.equal(afterCount, beforeCount, `journal grew from ${beforeCount} to ${afterCount} on a starter table`);
|
||||
});
|
||||
|
||||
const starterPromote = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/promote", { peer_id: peerIdForRows });
|
||||
await test("starter promote ships initial rows", async () => {
|
||||
assert.match(starterPromote.redirect, /msg=promoted/);
|
||||
});
|
||||
await test("test has the 2 initial templates (but not the post-ship row)", async () => {
|
||||
const c = sql(TEST.db, "SELECT COUNT(*) FROM dd_templates");
|
||||
assert.equal(c, "2");
|
||||
const has = sql(TEST.db, "SELECT COUNT(*) FROM dd_templates WHERE name='Welcome'");
|
||||
assert.equal(has, "1");
|
||||
const postship = sql(TEST.db, "SELECT COUNT(*) FROM dd_templates WHERE name='Post-ship row'");
|
||||
assert.equal(postship, "0");
|
||||
});
|
||||
|
||||
section("revert");
|
||||
runJs(MAIN.env, 'await Table.create("ephemeral");');
|
||||
const createOp = sql(MAIN.db, "SELECT op_id FROM _dd_ops WHERE op_type='create_table' AND payload LIKE '%ephemeral%' LIMIT 1");
|
||||
const revertRes = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/revert", { op_id: createOp });
|
||||
await test("revert returns 302", async () => {
|
||||
assert.equal(revertRes.status, 302);
|
||||
});
|
||||
await test("ephemeral table is gone after revert", async () => {
|
||||
const r = sql(MAIN.db, "SELECT COUNT(*) FROM _sc_tables WHERE name='ephemeral'");
|
||||
assert.equal(r, "0");
|
||||
});
|
||||
await test("drop_table op recorded as the revert", async () => {
|
||||
const r = sql(MAIN.db, "SELECT COUNT(*) FROM _dd_ops WHERE op_type='drop_table' AND payload LIKE '%ephemeral%'");
|
||||
assert.ok(parseInt(r, 10) >= 1);
|
||||
});
|
||||
|
||||
section("data_mode");
|
||||
const widgetsUuid = sql(MAIN.db, "SELECT uuid FROM _dd_entity_ids WHERE current_name='widgets'");
|
||||
const setRes = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/tables/set", {
|
||||
table_uuid: widgetsUuid,
|
||||
data_mode: "starter"
|
||||
});
|
||||
await test("tables/set redirects with success", async () => {
|
||||
assert.equal(setRes.status, 302);
|
||||
assert.match(setRes.redirect, /msg=set/);
|
||||
});
|
||||
await test("_dd_table_modes row inserted with starter", async () => {
|
||||
const r = sql(MAIN.db, `SELECT data_mode FROM _dd_table_modes WHERE table_uuid='${widgetsUuid}'`);
|
||||
assert.equal(r, "starter");
|
||||
});
|
||||
await test("users table is locked: cannot change data_mode", async () => {
|
||||
const usersUuid = sql(MAIN.db, "SELECT uuid FROM _dd_entity_ids WHERE current_name='users' AND kind='table'");
|
||||
const r = adminPost(MAIN, MAIN_COOKIES, "/admin/dev-deploy/tables/set", {
|
||||
table_uuid: usersUuid,
|
||||
data_mode: "managed"
|
||||
});
|
||||
assert.match(r.redirect || "", /err=/);
|
||||
const mode = sql(MAIN.db, `SELECT COUNT(*) FROM _dd_table_modes WHERE table_uuid='${usersUuid}'`);
|
||||
assert.equal(mode, "0", "users table should have no row in _dd_table_modes");
|
||||
});
|
||||
|
||||
section("machine-endpoint security");
|
||||
// Test that requests without HMAC are rejected
|
||||
await test("unsigned POST to ingest returns 400 (missing header)", async () => {
|
||||
const out = curl([
|
||||
"-sS", "-o", "/dev/null", "-w", "%{http_code}",
|
||||
"-X", "POST",
|
||||
"-H", "Content-Type: application/vnd.dev-deploy+json",
|
||||
"--data", '{"ops":[]}',
|
||||
`${TEST.url}/dev-deploy/api/ingest`
|
||||
]);
|
||||
assert.equal(out, "400");
|
||||
});
|
||||
await test("ingest with bogus signature returns 401", async () => {
|
||||
const out = curl([
|
||||
"-sS", "-o", "/dev/null", "-w", "%{http_code}",
|
||||
"-X", "POST",
|
||||
"-H", "Content-Type: application/vnd.dev-deploy+json",
|
||||
"-H", `X-DD-Env-Id: ${mainEnv}`,
|
||||
"-H", `X-DD-Timestamp: ${Date.now()}`,
|
||||
"-H", "X-DD-Nonce: deadbeefdeadbeefdeadbeefdeadbeef",
|
||||
"-H", "X-DD-Signature: 0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"--data", '{"ops":[]}',
|
||||
`${TEST.url}/dev-deploy/api/ingest`
|
||||
]);
|
||||
assert.equal(out, "401");
|
||||
});
|
||||
await test("ingest from unknown env_id returns 401", async () => {
|
||||
const out = curl([
|
||||
"-sS", "-o", "/dev/null", "-w", "%{http_code}",
|
||||
"-X", "POST",
|
||||
"-H", "Content-Type: application/vnd.dev-deploy+json",
|
||||
"-H", "X-DD-Env-Id: 00000000-0000-4000-8000-000000000000",
|
||||
"-H", `X-DD-Timestamp: ${Date.now()}`,
|
||||
"-H", "X-DD-Nonce: deadbeefdeadbeefdeadbeefdeadbeef",
|
||||
"-H", "X-DD-Signature: 0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"--data", '{"ops":[]}',
|
||||
`${TEST.url}/dev-deploy/api/ingest`
|
||||
]);
|
||||
assert.equal(out, "401");
|
||||
});
|
||||
await test("ingest with stale timestamp returns 401", async () => {
|
||||
const stale = String(Date.now() - 10 * 60 * 1000);
|
||||
const out = curl([
|
||||
"-sS", "-o", "/dev/null", "-w", "%{http_code}",
|
||||
"-X", "POST",
|
||||
"-H", "Content-Type: application/vnd.dev-deploy+json",
|
||||
"-H", `X-DD-Env-Id: ${mainEnv}`,
|
||||
"-H", `X-DD-Timestamp: ${stale}`,
|
||||
"-H", "X-DD-Nonce: deadbeefdeadbeefdeadbeefdeadbeef",
|
||||
"-H", "X-DD-Signature: 0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"--data", '{"ops":[]}',
|
||||
`${TEST.url}/dev-deploy/api/ingest`
|
||||
]);
|
||||
assert.equal(out, "401");
|
||||
});
|
||||
|
||||
section("admin auth");
|
||||
await test("unauthenticated GET /admin/dev-deploy/ returns 403", async () => {
|
||||
const out = curl(["-sS", "-o", "/dev/null", "-w", "%{http_code}", `${MAIN.url}/admin/dev-deploy/`]);
|
||||
assert.equal(out, "403");
|
||||
});
|
||||
|
||||
// --- summary ---
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) {
|
||||
console.log("\nFailures:");
|
||||
for (const f of failures) {
|
||||
console.log(` - ${f.name}`);
|
||||
console.log(` ${f.err.stack.split("\n").slice(0, 4).join("\n ")}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("test runner crashed:", err);
|
||||
process.exit(2);
|
||||
});
|
||||
57
dev-deploy/test/sc-exec.js
Normal file
57
dev-deploy/test/sc-exec.js
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
// Like `saltcorn run-js` but without the vm sandbox -- the supplied JS body
|
||||
// runs with full `require()` access so the e2e suite can drive Field /
|
||||
// TableConstraint / File creation. Invoked from the test runner via:
|
||||
//
|
||||
// source <env.sh> && node test/sc-exec.js "<javascript code>"
|
||||
|
||||
const { createRequire } = require("node:module");
|
||||
const path = require("node:path");
|
||||
|
||||
// Resolve @saltcorn/* against the Saltcorn checkout's node_modules. Node
|
||||
// resolves require from the script's path by default, which doesn't see
|
||||
// Saltcorn's deps; createRequire reroots the resolution. Layout:
|
||||
// <project-root>/
|
||||
// dev-deploy/test/sc-exec.js (this file)
|
||||
// saltcorn/packages/saltcorn-data/
|
||||
// So: up 2 to project root, then into saltcorn/packages/saltcorn-data/.
|
||||
const scRequire = createRequire(path.join(__dirname, "..", "..", "saltcorn", "packages", "saltcorn-data", "package.json"));
|
||||
|
||||
|
||||
const main = async () => {
|
||||
// Read code from stdin -- avoids shell-escape gymnastics for multi-line
|
||||
// bodies (notably \n which bash double-quotes pass through literally).
|
||||
const code = require("node:fs").readFileSync(0, "utf8");
|
||||
if (!code) {
|
||||
console.error("usage: pipe JS code into sc-exec.js's stdin");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const Plugin = scRequire("@saltcorn/data/models/plugin");
|
||||
const { init_multi_tenant } = scRequire("@saltcorn/data/db/state");
|
||||
await Plugin.loadAllPlugins();
|
||||
await init_multi_tenant(Plugin.loadAllPlugins, undefined, []);
|
||||
|
||||
const Table = scRequire("@saltcorn/data/models/table");
|
||||
const Field = scRequire("@saltcorn/data/models/field");
|
||||
const View = scRequire("@saltcorn/data/models/view");
|
||||
const Page = scRequire("@saltcorn/data/models/page");
|
||||
const Trigger = scRequire("@saltcorn/data/models/trigger");
|
||||
const TableConstraint = scRequire("@saltcorn/data/models/table_constraints");
|
||||
const File = scRequire("@saltcorn/data/models/file");
|
||||
const db = scRequire("@saltcorn/data/db");
|
||||
|
||||
// Build an async closure with the models + a re-rooted require in scope.
|
||||
// (`new Function` bodies don't inherit require from the script scope.)
|
||||
const fn = new Function(
|
||||
"Table", "Field", "View", "Page", "Trigger", "TableConstraint", "File", "db", "require",
|
||||
`return (async () => { ${code} })();`
|
||||
);
|
||||
await fn(Table, Field, View, Page, Trigger, TableConstraint, File, db, scRequire);
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err && err.stack ? err.stack : err);
|
||||
process.exit(1);
|
||||
});
|
||||
15
devServer.sh
Executable file
15
devServer.sh
Executable file
|
|
@ -0,0 +1,15 @@
|
|||
#!/usr/bin/env bash
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
source .dev-state/env.sh
|
||||
|
||||
# See startServer.sh for the rationale. Note: `dev:serve` watches Saltcorn
|
||||
# repo files but explicitly ignores plugins_folder/, so plugin source edits
|
||||
# still need an install step to land in localversion/ -- this handles it.
|
||||
saltcorn install-plugin -d ./dev-deploy 2>&1 | tail -2 || true
|
||||
|
||||
# dev:serve spawns `npm run tsc`, which needs cwd=upstream root.
|
||||
# Side effect: sessions.sqlite lands in saltcorn/ (not .dev-state/) when using
|
||||
# dev:serve. Don't run this alongside ./startServer.sh on the MAIN instance.
|
||||
cd saltcorn
|
||||
exec saltcorn dev:serve "$@"
|
||||
150
installSaltcorn.sh
Executable file
150
installSaltcorn.sh
Executable file
|
|
@ -0,0 +1,150 @@
|
|||
#!/usr/bin/env bash
|
||||
# installSaltcorn.sh -- reproduce this Saltcorn dev environment from scratch.
|
||||
#
|
||||
# Layout created:
|
||||
# <dest>/
|
||||
# saltcorn/ upstream saltcorn checkout (cloned + built)
|
||||
# .dev-state/ per-instance state (sqlite, files, env.sh, sessions)
|
||||
# startServer.sh launcher
|
||||
#
|
||||
# After this, `cd <dest> && ./startServer.sh` boots Saltcorn on
|
||||
# http://localhost:3000/.
|
||||
#
|
||||
# Usage: ./installSaltcorn.sh [destination] (default: ./saltcorn)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DEST="${1:-./saltcorn}"
|
||||
REPO_URL="https://github.com/saltcorn/saltcorn.git"
|
||||
ADMIN_EMAIL="admin@local"
|
||||
ADMIN_PASSWORD="AdminP@ss1"
|
||||
|
||||
# --- prerequisites ---
|
||||
|
||||
for cmd in git curl; do
|
||||
command -v "$cmd" >/dev/null || { echo "$cmd not found in PATH"; exit 1; }
|
||||
done
|
||||
|
||||
# Use a user-local nvm at ~/.nvm rather than system node, matching this dev
|
||||
# environment. The .dev-state/env.sh written below sources the same nvm, so
|
||||
# `saltcorn` keeps working in fresh shells without further setup.
|
||||
export NVM_DIR="$HOME/.nvm"
|
||||
NVM_VERSION="v0.40.1"
|
||||
|
||||
if [ ! -s "$NVM_DIR/nvm.sh" ]; then
|
||||
echo "==> nvm not found at $NVM_DIR -- installing $NVM_VERSION"
|
||||
curl -fsSL "https://raw.githubusercontent.com/nvm-sh/nvm/${NVM_VERSION}/install.sh" | bash
|
||||
if [ ! -s "$NVM_DIR/nvm.sh" ]; then
|
||||
echo "nvm install did not produce $NVM_DIR/nvm.sh"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# nvm.sh references some unset variables on load; relax 'set -u' while sourcing.
|
||||
set +u
|
||||
# shellcheck source=/dev/null
|
||||
. "$NVM_DIR/nvm.sh"
|
||||
set -u
|
||||
|
||||
echo "==> Ensuring node 20 is installed and active"
|
||||
nvm install 20
|
||||
nvm use 20
|
||||
|
||||
NODE_MAJOR=$(node -p 'process.versions.node.split(".")[0]')
|
||||
if [ "$NODE_MAJOR" -lt 18 ]; then
|
||||
echo "node >= 18 required, found $(node --version)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Refuse to clobber an existing saltcorn subfolder, but allow $DEST itself to
|
||||
# pre-exist (it might already contain dev-deploy/ or other user work).
|
||||
if [ -e "$DEST/saltcorn" ]; then
|
||||
echo "$DEST/saltcorn already exists; refusing to overwrite"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- clone + build ---
|
||||
|
||||
mkdir -p "$DEST"
|
||||
DEST_ABS="$(cd "$DEST" && pwd)"
|
||||
|
||||
echo "==> Cloning $REPO_URL into $DEST_ABS/saltcorn"
|
||||
git clone "$REPO_URL" "$DEST_ABS/saltcorn"
|
||||
|
||||
echo "==> npm install (resolves workspaces; takes several minutes)"
|
||||
(cd "$DEST_ABS/saltcorn" && npm install)
|
||||
|
||||
echo "==> npm run tsc"
|
||||
(cd "$DEST_ABS/saltcorn" && npm run tsc)
|
||||
|
||||
# --- .dev-state/ with generated session secret ---
|
||||
|
||||
DEV_DIR="$DEST_ABS/.dev-state"
|
||||
mkdir -p "$DEV_DIR/files"
|
||||
|
||||
SECRET=$(node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))')
|
||||
|
||||
cat > "$DEV_DIR/env.sh" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
# Source this file before running saltcorn: source .dev-state/env.sh
|
||||
# Keeps all dev state (DB + uploaded files) under .dev-state/ inside the
|
||||
# project root. Upstream saltcorn lives in <project-root>/saltcorn/.
|
||||
|
||||
__SC_DEV_DIR="\$(cd "\$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
|
||||
__SC_REPO_DIR="\$(dirname "\$__SC_DEV_DIR")"
|
||||
|
||||
export NVM_DIR="\$HOME/.nvm"
|
||||
# shellcheck disable=SC1091
|
||||
[ -s "\$NVM_DIR/nvm.sh" ] && . "\$NVM_DIR/nvm.sh"
|
||||
|
||||
export SQLITE_FILEPATH="\$__SC_DEV_DIR/saltcorn.sqlite"
|
||||
export SALTCORN_FILE_STORE="\$__SC_DEV_DIR/files"
|
||||
export SALTCORN_SESSION_SECRET="$SECRET"
|
||||
|
||||
# Put the in-tree CLI on PATH so \`saltcorn ...\` resolves to this checkout.
|
||||
case ":\$PATH:" in
|
||||
*":\$__SC_REPO_DIR/saltcorn/packages/saltcorn-cli/bin:"*) ;;
|
||||
*) export PATH="\$__SC_REPO_DIR/saltcorn/packages/saltcorn-cli/bin:\$PATH" ;;
|
||||
esac
|
||||
|
||||
unset __SC_DEV_DIR __SC_REPO_DIR
|
||||
EOF
|
||||
chmod +x "$DEV_DIR/env.sh"
|
||||
|
||||
# --- initialize schema and admin user ---
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
source "$DEV_DIR/env.sh"
|
||||
|
||||
echo "==> Initializing SQLite schema at $SQLITE_FILEPATH"
|
||||
saltcorn reset-schema -f
|
||||
|
||||
echo "==> Creating admin user $ADMIN_EMAIL"
|
||||
saltcorn create-user -a -e "$ADMIN_EMAIL" -p "$ADMIN_PASSWORD"
|
||||
|
||||
# --- startServer.sh ---
|
||||
|
||||
cat > "$DEST_ABS/startServer.sh" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
source .dev-state/env.sh
|
||||
|
||||
# Pick up any local plugin source changes before booting the server.
|
||||
# install-plugin copies ./dev-deploy into Saltcorn's
|
||||
# plugins_folder/.../localversion/, so source edits go live on each restart.
|
||||
# Failures here are non-fatal: the previously-installed version still loads.
|
||||
saltcorn install-plugin -d ./dev-deploy 2>&1 | tail -2 || true
|
||||
|
||||
# Saltcorn's SQLite session store writes sessions.sqlite at process cwd
|
||||
# (packages/server/routes/utils.js). Run from inside .dev-state/ so each
|
||||
# instance gets its own sessions.sqlite alongside its saltcorn.sqlite.
|
||||
cd .dev-state
|
||||
exec saltcorn serve "$@"
|
||||
EOF
|
||||
chmod +x "$DEST_ABS/startServer.sh"
|
||||
|
||||
echo
|
||||
echo "Done."
|
||||
echo " cd $DEST_ABS && ./startServer.sh"
|
||||
echo " -> http://localhost:3000/ (login: $ADMIN_EMAIL / $ADMIN_PASSWORD)"
|
||||
16
startServer.sh
Executable file
16
startServer.sh
Executable file
|
|
@ -0,0 +1,16 @@
|
|||
#!/usr/bin/env bash
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
source .dev-state/env.sh
|
||||
|
||||
# Pick up any local plugin source changes before booting the server.
|
||||
# install-plugin copies ./dev-deploy into Saltcorn's
|
||||
# plugins_folder/.../localversion/, so source edits go live on each restart.
|
||||
# Failures here are non-fatal: the previously-installed version still loads.
|
||||
saltcorn install-plugin -d ./dev-deploy 2>&1 | tail -2 || true
|
||||
|
||||
# Saltcorn's SQLite session store writes sessions.sqlite at process cwd
|
||||
# (packages/server/routes/utils.js). Run from inside .dev-state/ so each
|
||||
# instance gets its own sessions.sqlite alongside its saltcorn.sqlite.
|
||||
cd .dev-state
|
||||
exec saltcorn serve "$@"
|
||||
11
startServerTest.sh
Executable file
11
startServerTest.sh
Executable file
|
|
@ -0,0 +1,11 @@
|
|||
#!/usr/bin/env bash
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
source .dev-state-test/env.sh
|
||||
|
||||
# See startServer.sh for the rationale.
|
||||
saltcorn install-plugin -d ./dev-deploy 2>&1 | tail -2 || true
|
||||
|
||||
# See startServer.sh for why we cd into the state dir before serving.
|
||||
cd .dev-state-test
|
||||
exec saltcorn serve -p "$SALTCORN_PORT" "$@"
|
||||
Loading…
Add table
Reference in a new issue