diff --git a/.github/workflows/update_cap.yml b/.github/workflows/update_cap.yml index d21ab8f..7c829a3 100644 --- a/.github/workflows/update_cap.yml +++ b/.github/workflows/update_cap.yml @@ -51,8 +51,18 @@ jobs: echo "mirror unchanged" else git commit -m "mirror: update run/input/core ($(cut -c1-12 run/input/UPSTREAM_COMMIT))" - git pull --rebase origin main - git push origin HEAD:main + # a concurrent push to main between rebase and push loses the + # race — retry the rebase+push a few times before giving up + for i in 1 2 3; do + git pull --rebase origin main + if git push origin HEAD:main; then + break + elif [ "$i" = 3 ]; then + echo "push failed after 3 attempts" >&2 + exit 1 + fi + sleep $((i * 2)) + done fi - run: npm run assemble # one install covers the app AND the vendored core (its deps are part diff --git a/scripts/assemble-cap.js b/scripts/assemble-cap.js index 6acd4a0..fd10ad5 100644 --- a/scripts/assemble-cap.js +++ b/scripts/assemble-cap.js @@ -46,7 +46,9 @@ const COPY_IGNORE = new Set(["node_modules", "gen", "resources", "mta_archives", // Local, non-published entries kept across the wipe of run/output/cap2UI5 // (all gitignored) — avoids a full reinstall after each build. -const PRESERVE = new Set(["node_modules"]); +// (.core_node_modules.keep is the stash for core/node_modules, which the +// app lock places INSIDE the vendored core/ — see below.) +const PRESERVE = new Set(["node_modules", ".core_node_modules.keep"]); function copyDir(from, to, topLevel) { fs.mkdirSync(to, { recursive: true }); @@ -82,6 +84,13 @@ if (!fs.existsSync(path.join(coreSrc, "package.json"))) { } fs.mkdirSync(dest, { recursive: true }); +// core/node_modules lives inside the wiped core/ — stash it before the wipe +// (restored after the core vendor step), same preservation as the top-level +// node_modules. +const coreNm = path.join(dest, "core", "node_modules"); +const coreNmKeep = path.join(dest, ".core_node_modules.keep"); +fs.rmSync(coreNmKeep, { recursive: true, force: true }); +if (fs.existsSync(coreNm)) fs.renameSync(coreNm, coreNmKeep); for (const entry of fs.readdirSync(dest)) { if (PRESERVE.has(entry)) continue; fs.rmSync(path.join(dest, entry), { recursive: true, force: true }); @@ -90,10 +99,12 @@ copyDir(base, dest, true); console.log(`src → run/output/cap2UI5 (source skeleton copied, core dep path rewritten)`); // vendor the mirrored core package into the app (mirror-core already -// strips node_modules from the snapshot) +// strips node_modules from the snapshot), then restore the stashed +// core/node_modules install const coreDest = path.join(dest, "core"); fs.rmSync(coreDest, { recursive: true, force: true }); copyDir(coreSrc, coreDest, false); +if (fs.existsSync(coreNmKeep)) fs.renameSync(coreNmKeep, coreNm); console.log(` vendor core (from run/input/core) → core: ${countFiles(coreDest)} files`); const webappSrc = path.join(coreSrc, "app", "z2ui5", "webapp"); @@ -123,4 +134,35 @@ for (const [key, entry] of Object.entries(coreLock.packages || {})) { fs.writeFileSync(appLockPath, JSON.stringify(appLock, null, 2) + "\n"); console.log(` merge core lock → package-lock.json: ${merged} entries under core/node_modules/`); +// Guardrails over the blind string rewrite + lock merge — fail the build +// here instead of in the downstream `npm ci`: +{ + const problems = []; + let pkg; + try { + pkg = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8")); + } catch (e) { + problems.push(`package.json is not valid JSON after rewrite: ${e.message}`); + } + if (pkg && pkg.dependencies?.abap2UI5 !== "file:./core") { + problems.push(`package.json dependency abap2UI5 is "${pkg?.dependencies?.abap2UI5}", expected "file:./core"`); + } + for (const f of ["package.json", "package-lock.json"]) { + if (fs.readFileSync(path.join(dest, f), "utf8").includes("run/input/core")) { + problems.push(`${f} still references run/input/core after rewrite`); + } + } + const linkEntry = appLock.packages?.["node_modules/abap2UI5"]; + if (!linkEntry || linkEntry.resolved !== "core") { + problems.push(`lock entry node_modules/abap2UI5 does not link "core" (got ${JSON.stringify(linkEntry?.resolved)})`); + } + if (!appLock.packages?.["core"]) problems.push(`lock has no "core" package entry`); + if (merged === 0) problems.push("core lock merge produced 0 entries — empty/renamed core lock?"); + if (problems.length) { + console.error(`assemble: output validation FAILED —\n - ${problems.join("\n - ")}`); + process.exit(1); + } + console.log(` validate: dep rewrite + lock merge OK`); +} + console.log(`\nassembled → run/output/cap2UI5`); diff --git a/scripts/mirror-core.js b/scripts/mirror-core.js index 71b5749..6a872bb 100644 --- a/scripts/mirror-core.js +++ b/scripts/mirror-core.js @@ -12,6 +12,14 @@ * (same convention as the run/input snapshots in builder-abap2UI5-js). * run/input/core/ is wiped and rewritten on every run, so upstream * deletions propagate. + * + * The sha in UPSTREAM_HEAD (the trigger slot written by builder-abap2UI5-js) + * is honored when it is NEWER than the cloned default-branch HEAD (the + * fast-double-push race: the announced commit is not yet what a fresh clone + * sees). A stale slot — trigger_cap upstream is manual, so the slot lags — + * must not pin the nightly to an old core, so the newer of the two commits + * wins (committer date; equal-or-older slot ⇒ HEAD). If the slot sha cannot + * be fetched at all, the mirror falls back to HEAD with a warning. */ "use strict"; @@ -42,6 +50,13 @@ function copyCore(fromRepo, commit) { console.log(`mirror: builder-abap2UI5-js@${commit.slice(0, 12)} core/ → run/input/core/`); } +function pinnedSha() { + const slot = path.join(root, "UPSTREAM_HEAD"); + if (!fs.existsSync(slot)) return null; + const sha = fs.readFileSync(slot, "utf8").trim(); + return /^[0-9a-f]{40}$/.test(sha) ? sha : null; +} + const local = process.env.MIRROR_SOURCE; if (local) { const commit = execFileSync("git", ["-C", local, "rev-parse", "HEAD"]).toString().trim(); @@ -50,6 +65,23 @@ if (local) { fs.rmSync(tmp, { recursive: true, force: true }); try { execFileSync("git", ["clone", "--depth", "1", UPSTREAM, tmp], { stdio: "inherit" }); + const head = execFileSync("git", ["-C", tmp, "rev-parse", "HEAD"]).toString().trim(); + const pin = pinnedSha(); + if (pin && pin !== head) { + try { + execFileSync("git", ["-C", tmp, "fetch", "--depth", "1", "origin", pin], { stdio: "inherit" }); + const date = (sha) => + Number(execFileSync("git", ["-C", tmp, "show", "-s", "--format=%ct", sha]).toString().trim()); + if (date(pin) > date(head)) { + execFileSync("git", ["-C", tmp, "checkout", "--force", pin], { stdio: "inherit" }); + console.log(`mirror: honoring UPSTREAM_HEAD ${pin.slice(0, 12)} (newer than cloned HEAD ${head.slice(0, 12)})`); + } else { + console.log(`mirror: UPSTREAM_HEAD ${pin.slice(0, 12)} is stale — mirroring HEAD ${head.slice(0, 12)}`); + } + } catch (e) { + console.warn(`mirror: UPSTREAM_HEAD ${pin.slice(0, 12)} not fetchable — falling back to upstream HEAD`); + } + } const commit = execFileSync("git", ["-C", tmp, "rev-parse", "HEAD"]).toString().trim(); copyCore(tmp, commit); } finally { diff --git a/src/db/schema.cds b/src/db/schema.cds index 89f6334..e07e1fc 100644 --- a/src/db/schema.cds +++ b/src/db/schema.cds @@ -6,7 +6,8 @@ namespace cap2ui5; * abap2UI5 table Z2UI5_T_01). */ entity z2ui5_t_01 { - key id : UUID; - id_prev : UUID; - data : LargeString; + key id : UUID; + id_prev : UUID; + data : LargeString; + createdAt : Timestamp @cds.on.insert: $now; } diff --git a/src/srv/draft-retention.js b/src/srv/draft-retention.js new file mode 100644 index 0000000..b930d15 --- /dev/null +++ b/src/srv/draft-retention.js @@ -0,0 +1,41 @@ +const cds = require("@sap/cds"); + +/** + * Draft retention for cap2ui5.z2ui5_t_01. + * + * The store is append-only (one row per roundtrip, chained via id_prev for + * back-navigation), so without cleanup the table grows without bound in + * production. Rows older than the TTL are dead weight: their draft ids are + * long gone from any live browser session. + * + * Z2UI5_DRAFT_TTL_HOURS TTL in hours (default 24; 0 disables cleanup) + * + * deleteExpiredDrafts() is exported for tests; start() runs it once at + * startup and then hourly (unref'd, so it never keeps the process alive). + */ + +const ttlHours = () => { + const raw = process.env.Z2UI5_DRAFT_TTL_HOURS; + if (raw === undefined || raw === "") return 24; + const n = Number(raw); + return Number.isFinite(n) && n >= 0 ? n : 24; +}; + +async function deleteExpiredDrafts(now = Date.now()) { + const ttl = ttlHours(); + if (ttl === 0) return 0; + const { z2ui5_t_01 } = cds.entities("cap2ui5"); + const cutoff = new Date(now - ttl * 3600 * 1000).toISOString(); + const deleted = await DELETE.from(z2ui5_t_01).where({ createdAt: { "<": cutoff } }); + if (deleted) console.log(`[z2ui5] draft retention: deleted ${deleted} row(s) older than ${ttl}h`); + return deleted; +} + +function start() { + if (ttlHours() === 0) return; + const run = () => deleteExpiredDrafts().catch((e) => console.error("[z2ui5] draft retention failed:", e.message)); + run(); + setInterval(run, 3600 * 1000).unref(); +} + +module.exports = { deleteExpiredDrafts, start }; diff --git a/src/srv/server.js b/src/srv/server.js index 31b8d4f..07cbca2 100644 --- a/src/srv/server.js +++ b/src/srv/server.js @@ -16,7 +16,8 @@ const z2ui5_cl_util_http = require("abap2UI5/z2ui5_cl_util_http"); * /resources → the local UI5 runtime * * plus the two platform ports of this app: - * draft store → the CDS entity cap2ui5.z2ui5_t_01 (db/schema.cds) + * draft store → the CDS entity cap2ui5.z2ui5_t_01 (db/schema.cds), + * expired rows cleaned hourly (srv/draft-retention.js) * app discovery → this project's srv/app/ (custom apps) */ @@ -38,6 +39,10 @@ engine.set_store({ // package are found without registration). engine.register_app_dir(require("path").join(__dirname, "app")); +// Retention: the store above is append-only, so expired draft chains are +// pruned on a timer once the database is connected. +cds.on("served", () => require("./draft-retention").start()); + cds.on("bootstrap", (app) => { // Serve the local UI5 runtime at /resources (must be registered before the // CDS services so it is not shadowed by the OData/REST routing) — the app diff --git a/src/test/draft-retention.test.js b/src/test/draft-retention.test.js new file mode 100644 index 0000000..16a8c4c --- /dev/null +++ b/src/test/draft-retention.test.js @@ -0,0 +1,43 @@ +/** + * Draft retention — expired rows in cap2ui5.z2ui5_t_01 are deleted, fresh + * ones survive (see srv/draft-retention.js; the store itself is append-only). + */ +const path = require("path"); +const cds = require("@sap/cds"); + +cds.test(path.join(__dirname, "..")); + +const { deleteExpiredDrafts } = require("../srv/draft-retention"); + +describe("draft retention", () => { + test("deletes rows older than the TTL, keeps fresh ones", async () => { + const { z2ui5_t_01 } = cds.entities("cap2ui5"); + const now = Date.now(); + const old = { id: cds.utils.uuid(), data: "{}" }; + const fresh = { id: cds.utils.uuid(), data: "{}" }; + await INSERT.into(z2ui5_t_01).entries([old, fresh]); + // @cds.on.insert stamps $now on insert — backdate via UPDATE instead + await UPDATE(z2ui5_t_01, old.id).with({ createdAt: new Date(now - 25 * 3600 * 1000).toISOString() }); + await UPDATE(z2ui5_t_01, fresh.id).with({ createdAt: new Date(now - 1 * 3600 * 1000).toISOString() }); + + await deleteExpiredDrafts(now); // default TTL: 24h + + expect(await SELECT.one.from(z2ui5_t_01).where({ id: old.id })).toBeUndefined(); + expect(await SELECT.one.from(z2ui5_t_01).where({ id: fresh.id })).toBeDefined(); + }); + + test("Z2UI5_DRAFT_TTL_HOURS=0 disables cleanup", async () => { + const { z2ui5_t_01 } = cds.entities("cap2ui5"); + const stale = { id: cds.utils.uuid(), data: "{}" }; + await INSERT.into(z2ui5_t_01).entries([stale]); + await UPDATE(z2ui5_t_01, stale.id).with({ createdAt: new Date(Date.now() - 100 * 3600 * 1000).toISOString() }); + + process.env.Z2UI5_DRAFT_TTL_HOURS = "0"; + try { + expect(await deleteExpiredDrafts()).toBe(0); + } finally { + delete process.env.Z2UI5_DRAFT_TTL_HOURS; + } + expect(await SELECT.one.from(z2ui5_t_01).where({ id: stale.id })).toBeDefined(); + }); +}); diff --git a/src/test/northwind.test.js b/src/test/northwind.test.js new file mode 100644 index 0000000..783e64a --- /dev/null +++ b/src/test/northwind.test.js @@ -0,0 +1,34 @@ +/** + * NorthwindCustomers READ handler (srv/z2ui5-service.js) — the request must + * be forwarded to the remote 'northwind' OData service and its rows + * returned through the AdminService projection. The remote is mocked at + * cds.connect.to, so the test runs offline and only exercises our wiring. + */ +const path = require("path"); +const cds = require("@sap/cds"); + +const { GET } = cds.test(path.join(__dirname, "..")); + +describe("NorthwindCustomers remote proxy", () => { + test("READ forwards the query to the northwind service", async () => { + const rows = [ + { CustomerID: "ALFKI", CompanyName: "Alfreds Futterkiste" }, + { CustomerID: "ANATR", CompanyName: "Ana Trujillo Emparedados" }, + ]; + const seen = []; + const orig = cds.connect.to; + cds.connect.to = async function (name, ...rest) { + if (name === "northwind") return { run: async (query) => (seen.push(query), rows) }; + return orig.call(this, name, ...rest); + }; + try { + const res = await GET("/odata/v4/admin/NorthwindCustomers"); + expect(res.status).toBe(200); + expect(res.data.value.map((r) => r.CustomerID)).toEqual(["ALFKI", "ANATR"]); + expect(res.data.value[0].CompanyName).toBe("Alfreds Futterkiste"); + expect(seen).toHaveLength(1); // exactly one forwarded query + } finally { + cds.connect.to = orig; + } + }); +}); diff --git a/src/test/port-contract.test.js b/src/test/port-contract.test.js new file mode 100644 index 0000000..4b1d942 --- /dev/null +++ b/src/test/port-contract.test.js @@ -0,0 +1,45 @@ +/** + * Port-contract pin — the seam surface both this CAP app and the browser + * build (builder-cap2UI5-web) wire against, documented upstream in + * abap2UI5's AGENTS.md ("Downstream JS Port Contract"). If one of these + * assertions fails after a core update, a load-bearing seam changed and the + * consumers (srv/server.js, the web build's entry.mjs/gen-registry.mjs) + * must be adjusted in the same change set. + */ +const engine = require("abap2UI5/engine"); +const z2ui5_cl_util = require("abap2UI5/z2ui5_cl_util"); +const z2ui5_cl_core_srv_draft = require("abap2UI5/z2ui5_cl_core_srv_draft"); +const z2ui5_cl_http_handler = require("abap2UI5/z2ui5_cl_http_handler"); + +describe("abap2UI5 port contract", () => { + test("engine exposes the platform seam", () => { + expect(typeof engine.roundtrip).toBe("function"); + expect(typeof engine.bootstrap_html).toBe("function"); + expect(typeof engine.set_store).toBe("function"); + expect(typeof engine.register_app_dir).toBe("function"); + expect(typeof engine.ui5_resources_dir).toBe("function"); + expect(typeof engine.WEBAPP_DIR).toBe("string"); + }); + + test("draft store injection point and app-class registry exist", () => { + expect(typeof z2ui5_cl_core_srv_draft.set_store).toBe("function"); + expect(typeof z2ui5_cl_util.register_app_class).toBe("function"); + }); + + test("http handler answers the wire format { body, status_code }", async () => { + // minimal roundtrip through the real handler — no CDS involved, the + // draft store is swapped for an in-memory map for this test + const mem = new Map(); + z2ui5_cl_core_srv_draft.set_store({ + load: async (id) => mem.get(id), + save: async (e) => void mem.set(e.id, e), + }); + const res = await z2ui5_cl_http_handler({ + data: { + value: { S_FRONT: { ORIGIN: "http://localhost", PATHNAME: "/", SEARCH: "", HASH: "" } }, + }, + }); + expect(res).toHaveProperty("S_FRONT.APP"); + expect(res.S_FRONT.ID).toMatch(/^[0-9a-f-]{36}$/); + }); +});