Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions .github/workflows/update_cap.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 44 additions & 2 deletions scripts/assemble-cap.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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 });
Expand All @@ -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");
Expand Down Expand Up @@ -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`);
32 changes: 32 additions & 0 deletions scripts/mirror-core.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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();
Expand All @@ -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 {
Expand Down
7 changes: 4 additions & 3 deletions src/db/schema.cds
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
41 changes: 41 additions & 0 deletions src/srv/draft-retention.js
Original file line number Diff line number Diff line change
@@ -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 };
7 changes: 6 additions & 1 deletion src/srv/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
*/

Expand All @@ -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
Expand Down
43 changes: 43 additions & 0 deletions src/test/draft-retention.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
34 changes: 34 additions & 0 deletions src/test/northwind.test.js
Original file line number Diff line number Diff line change
@@ -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;
}
});
});
45 changes: 45 additions & 0 deletions src/test/port-contract.test.js
Original file line number Diff line number Diff line change
@@ -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}$/);
});
});