@openlup/platform 0.0.0-stage → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/LICENSE +204 -0
  3. package/README.md +125 -2
  4. package/dist/bff/dispatch.d.ts +27 -0
  5. package/dist/bff/dispatch.js +47 -0
  6. package/dist/bff/envelope.d.ts +46 -0
  7. package/dist/bff/envelope.js +80 -0
  8. package/dist/bff/health.d.ts +24 -0
  9. package/dist/bff/health.js +19 -0
  10. package/dist/bff/index.d.ts +6 -0
  11. package/dist/bff/index.js +6 -0
  12. package/dist/bff/lazyRoute.d.ts +4 -0
  13. package/dist/bff/lazyRoute.js +13 -0
  14. package/dist/bff/requestContext.d.ts +17 -0
  15. package/dist/bff/requestContext.js +26 -0
  16. package/dist/bff/routerRewrite.d.ts +2 -0
  17. package/dist/bff/routerRewrite.js +31 -0
  18. package/dist/blob/binding.d.ts +16 -0
  19. package/dist/blob/binding.js +37 -0
  20. package/dist/blob/filesystem.d.ts +8 -0
  21. package/dist/blob/filesystem.js +71 -0
  22. package/dist/blob/index.d.ts +6 -0
  23. package/dist/blob/index.js +6 -0
  24. package/dist/blob/s3.d.ts +32 -0
  25. package/dist/blob/s3.js +56 -0
  26. package/dist/blob/s3Client.d.ts +25 -0
  27. package/dist/blob/s3Client.js +76 -0
  28. package/dist/blob/supabase.d.ts +40 -0
  29. package/dist/blob/supabase.js +59 -0
  30. package/dist/blob/vercel.d.ts +63 -0
  31. package/dist/blob/vercel.js +69 -0
  32. package/dist/composition/composeBundle.d.ts +47 -0
  33. package/dist/composition/composeBundle.js +45 -0
  34. package/dist/composition/httpBinding.d.ts +8 -0
  35. package/dist/composition/httpBinding.js +18 -0
  36. package/dist/composition/index.d.ts +3 -0
  37. package/dist/composition/index.js +3 -0
  38. package/dist/composition/nodeHost.d.ts +103 -0
  39. package/dist/composition/nodeHost.js +145 -0
  40. package/dist/data/binding.d.ts +34 -0
  41. package/dist/data/binding.js +92 -0
  42. package/dist/data/index.d.ts +5 -0
  43. package/dist/data/index.js +5 -0
  44. package/dist/data/postgresGateway.d.ts +42 -0
  45. package/dist/data/postgresGateway.js +135 -0
  46. package/dist/data/queryBuilder.d.ts +54 -0
  47. package/dist/data/queryBuilder.js +240 -0
  48. package/dist/data/queryBuilderSql.d.ts +76 -0
  49. package/dist/data/queryBuilderSql.js +145 -0
  50. package/dist/data/supabaseClientFactory.d.ts +34 -0
  51. package/dist/data/supabaseClientFactory.js +56 -0
  52. package/dist/data/supabaseGateway.d.ts +22 -0
  53. package/dist/data/supabaseGateway.js +49 -0
  54. package/dist/http/deadline.d.ts +20 -0
  55. package/dist/http/deadline.js +93 -0
  56. package/dist/http/index.d.ts +6 -0
  57. package/dist/http/index.js +5 -0
  58. package/dist/http/requestAdapter.d.ts +38 -0
  59. package/dist/http/requestAdapter.js +203 -0
  60. package/dist/http/runtime.d.ts +83 -0
  61. package/dist/http/runtime.js +199 -0
  62. package/dist/http/security.d.ts +44 -0
  63. package/dist/http/security.js +114 -0
  64. package/dist/http/staticAssets.d.ts +3 -0
  65. package/dist/http/staticAssets.js +42 -0
  66. package/dist/http/types.d.ts +23 -0
  67. package/dist/http/types.js +6 -0
  68. package/dist/job-ledger/index.d.ts +5 -0
  69. package/dist/job-ledger/index.js +3 -0
  70. package/dist/job-ledger/ports.d.ts +10 -0
  71. package/dist/job-ledger/ports.js +7 -0
  72. package/dist/job-ledger/postgres.d.ts +10 -0
  73. package/dist/job-ledger/postgres.js +101 -0
  74. package/dist/job-ledger/sql.d.ts +7 -0
  75. package/dist/job-ledger/sql.js +10 -0
  76. package/dist/job-ledger/supabase.d.ts +77 -0
  77. package/dist/job-ledger/supabase.js +202 -0
  78. package/dist/kernel/index.d.ts +1 -0
  79. package/dist/kernel/index.js +1 -0
  80. package/dist/kernel/platformKernel.d.ts +34 -0
  81. package/dist/kernel/platformKernel.js +115 -0
  82. package/dist/migrations/index.d.ts +1 -0
  83. package/dist/migrations/index.js +1 -0
  84. package/dist/migrations/postgresRunner.d.ts +50 -0
  85. package/dist/migrations/postgresRunner.js +176 -0
  86. package/dist/scheduler/advisoryLock.d.ts +44 -0
  87. package/dist/scheduler/advisoryLock.js +114 -0
  88. package/dist/scheduler/cronGuard.d.ts +10 -0
  89. package/dist/scheduler/cronGuard.js +19 -0
  90. package/dist/scheduler/cronSecret.d.ts +5 -0
  91. package/dist/scheduler/cronSecret.js +23 -0
  92. package/dist/scheduler/handlers.d.ts +9 -0
  93. package/dist/scheduler/handlers.js +10 -0
  94. package/dist/scheduler/httpEntry.d.ts +16 -0
  95. package/dist/scheduler/httpEntry.js +31 -0
  96. package/dist/scheduler/index.d.ts +9 -0
  97. package/dist/scheduler/index.js +9 -0
  98. package/dist/scheduler/jobRegistry.d.ts +21 -0
  99. package/dist/scheduler/jobRegistry.js +39 -0
  100. package/dist/scheduler/jobs.d.ts +48 -0
  101. package/dist/scheduler/jobs.js +92 -0
  102. package/dist/scheduler/nodeScheduler.d.ts +42 -0
  103. package/dist/scheduler/nodeScheduler.js +73 -0
  104. package/dist/scheduler/vercelScheduler.d.ts +29 -0
  105. package/dist/scheduler/vercelScheduler.js +51 -0
  106. package/package.json +108 -4
  107. package/release-gates.json +121 -0
  108. package/sql/20260809150000_job_run_ledger.sql +301 -0
  109. package/sql/20260809150001_job_run_ledger_v3.sql +70 -0
  110. package/src/bff/dispatch.ts +81 -0
  111. package/src/bff/envelope.ts +142 -0
  112. package/src/bff/health.ts +37 -0
  113. package/src/bff/index.ts +30 -0
  114. package/src/bff/lazyRoute.ts +28 -0
  115. package/src/bff/requestContext.ts +48 -0
  116. package/src/bff/routerRewrite.ts +38 -0
  117. package/src/blob/binding.ts +55 -0
  118. package/src/blob/filesystem.ts +86 -0
  119. package/src/blob/index.ts +17 -0
  120. package/src/blob/s3.ts +87 -0
  121. package/src/blob/s3Client.ts +91 -0
  122. package/src/blob/supabase.ts +88 -0
  123. package/src/blob/vercel.ts +127 -0
  124. package/src/composition/composeBundle.ts +109 -0
  125. package/src/composition/httpBinding.ts +30 -0
  126. package/src/composition/index.ts +17 -0
  127. package/src/composition/nodeHost.ts +289 -0
  128. package/src/data/binding.ts +157 -0
  129. package/src/data/index.ts +33 -0
  130. package/src/data/postgresGateway.ts +186 -0
  131. package/src/data/queryBuilder.ts +300 -0
  132. package/src/data/queryBuilderSql.ts +183 -0
  133. package/src/data/supabaseClientFactory.ts +92 -0
  134. package/src/data/supabaseGateway.ts +76 -0
  135. package/src/http/deadline.ts +117 -0
  136. package/src/http/index.ts +36 -0
  137. package/src/http/requestAdapter.ts +215 -0
  138. package/src/http/runtime.ts +319 -0
  139. package/src/http/security.ts +155 -0
  140. package/src/http/staticAssets.ts +42 -0
  141. package/src/http/types.ts +31 -0
  142. package/src/job-ledger/index.ts +25 -0
  143. package/src/job-ledger/ports.ts +27 -0
  144. package/src/job-ledger/postgres.ts +121 -0
  145. package/src/job-ledger/sql.ts +12 -0
  146. package/src/job-ledger/supabase.ts +294 -0
  147. package/src/kernel/index.ts +17 -0
  148. package/src/kernel/platformKernel.ts +153 -0
  149. package/src/migrations/index.ts +10 -0
  150. package/src/migrations/postgresRunner.ts +255 -0
  151. package/src/scheduler/advisoryLock.ts +145 -0
  152. package/src/scheduler/cronGuard.ts +30 -0
  153. package/src/scheduler/cronSecret.ts +26 -0
  154. package/src/scheduler/handlers.ts +24 -0
  155. package/src/scheduler/httpEntry.ts +49 -0
  156. package/src/scheduler/index.ts +47 -0
  157. package/src/scheduler/jobRegistry.ts +57 -0
  158. package/src/scheduler/jobs.ts +119 -0
  159. package/src/scheduler/nodeScheduler.ts +123 -0
  160. package/src/scheduler/vercelScheduler.ts +71 -0
@@ -0,0 +1,29 @@
1
+ import type { PlatformHttpRequest, SchedulerPort } from "@openlup/core/platform-runtime";
2
+ import type { ScheduledJobDeclaration } from "./jobRegistry.js";
3
+ type CronEnv = Record<string, string | undefined> & {
4
+ CRON_SECRET?: string;
5
+ };
6
+ export { bearerToken } from "./cronSecret.js";
7
+ /**
8
+ * Verify a cron invocation is authentic. Fail-CLOSED:
9
+ * - CRON_SECRET unset -> false (never authorize when no secret configured)
10
+ * - presented Bearer token != CRON_SECRET -> false
11
+ * - presented Bearer token == CRON_SECRET -> true
12
+ * This is the single canonical check that replaces the per-handler inline comparisons.
13
+ */
14
+ export declare function verifyCronAuth(req: PlatformHttpRequest, env: CronEnv): boolean;
15
+ export interface VercelSchedulerAdapter extends SchedulerPort {
16
+ /** The jobs this scheduler knows about, derived from the platform-runtime single source. */
17
+ readonly jobs: readonly ScheduledJobDeclaration[];
18
+ }
19
+ /**
20
+ * Build the Vercel cron scheduler adapter.
21
+ *
22
+ * `register()` validates the job is part of the declared registry (so callers can't silently
23
+ * register a job Vercel will never fire) but does NOT start a timer — Vercel's cron does that.
24
+ */
25
+ export declare function createVercelScheduler(options: {
26
+ env: CronEnv;
27
+ /** The deployment's declared jobs (see `loadJobRegistry`). */
28
+ jobs: readonly ScheduledJobDeclaration[];
29
+ }): VercelSchedulerAdapter;
@@ -0,0 +1,51 @@
1
+ // Vercel cron SchedulerPort adapter.
2
+ //
3
+ // On Vercel, scheduling is declarative: an adopter-owned `crons` manifest generated from the
4
+ // platform-runtime configuration (the same single source as `jobs`) tells Vercel to GET
5
+ // each `/api/cron/*` endpoint on its schedule. So `register()` is a no-op verification step here —
6
+ // the platform owns the timer. What this adapter OWNS is invocation authenticity: every cron
7
+ // endpoint must verify `Authorization: Bearer ${CRON_SECRET}`, fail-closed when the secret is unset.
8
+ //
9
+ // `verifyCronAuth(req, env)` is the reusable form of a bearer check against CRON_SECRET; cron
10
+ // handlers and the Node host call it instead of re-implementing the comparison.
11
+ import { bearerToken, cronSecretMatches } from "./cronSecret.js";
12
+ export { bearerToken } from "./cronSecret.js";
13
+ /**
14
+ * Verify a cron invocation is authentic. Fail-CLOSED:
15
+ * - CRON_SECRET unset -> false (never authorize when no secret configured)
16
+ * - presented Bearer token != CRON_SECRET -> false
17
+ * - presented Bearer token == CRON_SECRET -> true
18
+ * This is the single canonical check that replaces the per-handler inline comparisons.
19
+ */
20
+ export function verifyCronAuth(req, env) {
21
+ const expected = env.CRON_SECRET;
22
+ if (!expected) {
23
+ return false;
24
+ }
25
+ return cronSecretMatches(bearerToken(req), expected);
26
+ }
27
+ /**
28
+ * Build the Vercel cron scheduler adapter.
29
+ *
30
+ * `register()` validates the job is part of the declared registry (so callers can't silently
31
+ * register a job Vercel will never fire) but does NOT start a timer — Vercel's cron does that.
32
+ */
33
+ export function createVercelScheduler(options) {
34
+ const env = options.env;
35
+ const jobs = options.jobs;
36
+ const knownIds = new Set(jobs.map((job) => job.jobId));
37
+ return {
38
+ jobs,
39
+ register(input) {
40
+ // Vercel owns the timer through the adopter manifest. We only guard that the job is declared,
41
+ // surfacing drift between code and the single-source registry at registration time.
42
+ if (!knownIds.has(input.jobId)) {
43
+ throw new Error(`vercel-scheduler: job "${input.jobId}" is not declared in config/platform-runtime.json crons`);
44
+ }
45
+ // No-op timer: the handler at the cron path is invoked by Vercel directly.
46
+ },
47
+ verifyInvocation(req) {
48
+ return verifyCronAuth(req, env);
49
+ },
50
+ };
51
+ }
package/package.json CHANGED
@@ -1,6 +1,110 @@
1
1
  {
2
2
  "name": "@openlup/platform",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.15.0",
4
+ "private": false,
5
+ "description": "Runnable server substrate: Node HTTP runtime, BFF dispatch, scheduler, storage, data gateways and bundle composition; development preview.",
6
+ "license": "Apache-2.0",
7
+ "type": "module",
8
+ "engines": {
9
+ "node": "24.x"
10
+ },
11
+ "packageManager": "npm@11.19.0",
12
+ "sideEffects": false,
13
+ "files": [
14
+ "dist/**",
15
+ "src/**",
16
+ "sql/**",
17
+ "README.md",
18
+ "CHANGELOG.md",
19
+ "LICENSE",
20
+ "release-gates.json"
21
+ ],
22
+ "scripts": {
23
+ "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.build.json",
24
+ "test": "vitest run test",
25
+ "ci": "npm run build && npm run test && npm run api:check",
26
+ "api:check": "node --experimental-strip-types ./scripts/api-contract.ts",
27
+ "test:consumer": "node --experimental-strip-types ./scripts/consumer-proof.ts",
28
+ "ci:required": "npm run test:consumer",
29
+ "prepack": "npm run build",
30
+ "prepublishOnly": "node -e \"throw new Error('PLATFORM_DIRECTORY_PUBLISH_REFUSED')\"",
31
+ "api:update": "npm run build && node --experimental-strip-types ./scripts/api-contract.ts --update"
32
+ },
33
+ "peerDependencies": {
34
+ "@openlup/core": "0.15.0",
35
+ "pg": "^8.22.0",
36
+ "@aws-sdk/client-s3": "^3.1144.0",
37
+ "@types/pg": "^8.20.0"
38
+ },
39
+ "peerDependenciesMeta": {
40
+ "pg": {
41
+ "optional": true
42
+ },
43
+ "@aws-sdk/client-s3": {
44
+ "optional": true
45
+ },
46
+ "@types/pg": {
47
+ "optional": true
48
+ }
49
+ },
50
+ "dependencies": {
51
+ "zod": "^4.4.3"
52
+ },
53
+ "exports": {
54
+ "./http": {
55
+ "types": "./dist/http/index.d.ts",
56
+ "import": "./dist/http/index.js",
57
+ "default": "./dist/http/index.js"
58
+ },
59
+ "./bff": {
60
+ "types": "./dist/bff/index.d.ts",
61
+ "import": "./dist/bff/index.js",
62
+ "default": "./dist/bff/index.js"
63
+ },
64
+ "./scheduler": {
65
+ "types": "./dist/scheduler/index.d.ts",
66
+ "import": "./dist/scheduler/index.js",
67
+ "default": "./dist/scheduler/index.js"
68
+ },
69
+ "./blob": {
70
+ "types": "./dist/blob/index.d.ts",
71
+ "import": "./dist/blob/index.js",
72
+ "default": "./dist/blob/index.js"
73
+ },
74
+ "./data": {
75
+ "types": "./dist/data/index.d.ts",
76
+ "import": "./dist/data/index.js",
77
+ "default": "./dist/data/index.js"
78
+ },
79
+ "./migrations": {
80
+ "types": "./dist/migrations/index.d.ts",
81
+ "import": "./dist/migrations/index.js",
82
+ "default": "./dist/migrations/index.js"
83
+ },
84
+ "./job-ledger": {
85
+ "types": "./dist/job-ledger/index.d.ts",
86
+ "import": "./dist/job-ledger/index.js",
87
+ "default": "./dist/job-ledger/index.js"
88
+ },
89
+ "./kernel": {
90
+ "types": "./dist/kernel/index.d.ts",
91
+ "import": "./dist/kernel/index.js",
92
+ "default": "./dist/kernel/index.js"
93
+ },
94
+ "./composition": {
95
+ "types": "./dist/composition/index.d.ts",
96
+ "import": "./dist/composition/index.js",
97
+ "default": "./dist/composition/index.js"
98
+ }
99
+ },
100
+ "repository": {
101
+ "type": "git",
102
+ "url": "git+https://github.com/openlup/openlup.git",
103
+ "directory": "packages/platform"
104
+ },
105
+ "publishConfig": {
106
+ "access": "public",
107
+ "provenance": true,
108
+ "tag": "preview"
109
+ }
110
+ }
@@ -0,0 +1,121 @@
1
+ {
2
+ "schemaVersion": 5,
3
+ "kind": "rail",
4
+ "compatibilityPolicies": {
5
+ "internal-candidate": "Development-preview API; no stable compatibility claim."
6
+ },
7
+ "packageSurface": {
8
+ "./http": {
9
+ "role": "rail",
10
+ "maturity": "experimental",
11
+ "compatibilityPolicy": "internal-candidate",
12
+ "packageSmokeEvidence": [
13
+ "test/packageBoundary.test.ts",
14
+ "test/http/runtime.test.ts"
15
+ ],
16
+ "entrypoint": "dist/http/index.d.ts",
17
+ "snapshot": "api/http.api.md"
18
+ },
19
+ "./bff": {
20
+ "role": "rail",
21
+ "maturity": "experimental",
22
+ "compatibilityPolicy": "internal-candidate",
23
+ "packageSmokeEvidence": [
24
+ "test/packageBoundary.test.ts",
25
+ "test/bff/dispatch.test.ts"
26
+ ],
27
+ "entrypoint": "dist/bff/index.d.ts",
28
+ "snapshot": "api/bff.api.md"
29
+ },
30
+ "./scheduler": {
31
+ "role": "rail",
32
+ "maturity": "experimental",
33
+ "compatibilityPolicy": "internal-candidate",
34
+ "packageSmokeEvidence": [
35
+ "test/packageBoundary.test.ts",
36
+ "test/scheduler/jobs.test.ts"
37
+ ],
38
+ "entrypoint": "dist/scheduler/index.d.ts",
39
+ "snapshot": "api/scheduler.api.md"
40
+ },
41
+ "./blob": {
42
+ "role": "rail",
43
+ "maturity": "experimental",
44
+ "compatibilityPolicy": "internal-candidate",
45
+ "packageSmokeEvidence": [
46
+ "test/packageBoundary.test.ts",
47
+ "test/blob/binding.test.ts"
48
+ ],
49
+ "entrypoint": "dist/blob/index.d.ts",
50
+ "snapshot": "api/blob.api.md"
51
+ },
52
+ "./data": {
53
+ "role": "rail",
54
+ "maturity": "experimental",
55
+ "compatibilityPolicy": "internal-candidate",
56
+ "packageSmokeEvidence": [
57
+ "test/packageBoundary.test.ts",
58
+ "test/data/binding.test.ts"
59
+ ],
60
+ "entrypoint": "dist/data/index.d.ts",
61
+ "snapshot": "api/data.api.md"
62
+ },
63
+ "./migrations": {
64
+ "role": "rail",
65
+ "maturity": "experimental",
66
+ "compatibilityPolicy": "internal-candidate",
67
+ "packageSmokeEvidence": [
68
+ "test/packageBoundary.test.ts",
69
+ "test/migrations/postgresRunner.test.ts"
70
+ ],
71
+ "entrypoint": "dist/migrations/index.d.ts",
72
+ "snapshot": "api/migrations.api.md"
73
+ },
74
+ "./job-ledger": {
75
+ "role": "rail",
76
+ "maturity": "experimental",
77
+ "compatibilityPolicy": "internal-candidate",
78
+ "packageSmokeEvidence": [
79
+ "test/packageBoundary.test.ts",
80
+ "test/job-ledger/postgres.test.ts"
81
+ ],
82
+ "entrypoint": "dist/job-ledger/index.d.ts",
83
+ "snapshot": "api/job-ledger.api.md"
84
+ },
85
+ "./kernel": {
86
+ "role": "rail",
87
+ "maturity": "experimental",
88
+ "compatibilityPolicy": "internal-candidate",
89
+ "packageSmokeEvidence": [
90
+ "test/packageBoundary.test.ts",
91
+ "test/kernel/platformKernel.test.ts"
92
+ ],
93
+ "entrypoint": "dist/kernel/index.d.ts",
94
+ "snapshot": "api/kernel.api.md"
95
+ },
96
+ "./composition": {
97
+ "role": "rail",
98
+ "maturity": "experimental",
99
+ "compatibilityPolicy": "internal-candidate",
100
+ "packageSmokeEvidence": [
101
+ "test/packageBoundary.test.ts",
102
+ "test/composition/composeBundle.test.ts"
103
+ ],
104
+ "entrypoint": "dist/composition/index.d.ts",
105
+ "snapshot": "api/composition.api.md"
106
+ }
107
+ },
108
+ "pack": {
109
+ "code": {
110
+ "maxPackedBytes": 131072,
111
+ "maxFiles": 240
112
+ },
113
+ "docs": {
114
+ "maxPackedBytes": 16384,
115
+ "maxFiles": 12
116
+ },
117
+ "maxPackedBytes": 196608,
118
+ "maxUnpackedBytes": 1048576,
119
+ "maxFiles": 256
120
+ }
121
+ }
@@ -0,0 +1,301 @@
1
+ -- Public platform job-run ledger: the two relations a scheduled job needs to be
2
+ -- mutually exclusive across runners, and the two verbs its runtime actually calls.
3
+ --
4
+ -- WHAT THIS FORWARD SHIPS, AND WHY IT IS TWO VERBS AND NOT THREE. The second bundle
5
+ -- already has reads, a queue, a customer mutation, an operator mutation and a shipment
6
+ -- machine. What it has never had is the thing that lets a *scheduled* job run at all:
7
+ -- somewhere to say "this attempt is mine, nobody else start", and somewhere to say
8
+ -- "it is finished, and this is what it did". The consumer of this ledger asks for
9
+ -- exactly those two things -- a claim and a finish -- and for nothing else. There is
10
+ -- no heartbeat, no renew and no extend anywhere in the runtime that drives it, so
11
+ -- there is none here: a lease is issued once, and it is released by finishing or by
12
+ -- expiring. A failure is a finish with status 'failed', not a separate verb.
13
+ --
14
+ -- EXCLUSIVITY IS THE CONTROL ROW, NOT AN ADVISORY LOCK. The claim upserts the job's
15
+ -- control row and then takes `FOR UPDATE` on it. A second claimer arriving in the same
16
+ -- instant blocks on that row rather than racing past it, and once the first claimer
17
+ -- commits it reads a live lease and is refused. This is deliberate and it is the
18
+ -- property the harness proves with two concurrent connections. An advisory lock would
19
+ -- be a different machine: it is not carried by the row, so it would vanish with the
20
+ -- session and leave the ledger claiming a run that nobody holds. The lease, in
21
+ -- contrast, outlives the connection and is what makes a crashed runner recoverable --
22
+ -- after the lease elapses, and not before.
23
+ --
24
+ -- WHAT A FINISH IS ALLOWED TO DO. A finish always stamps its own run row, because a
25
+ -- runner that lost its lease still owes the ledger the truth about what it did. It
26
+ -- releases the *job* only when the control row still carries its own token. So a
27
+ -- zombie -- a runner whose lease expired and whose job was re-claimed by somebody else
28
+ -- -- writes its own epitaph and returns false, and cannot clear a lease it no longer
29
+ -- holds. That returned boolean is the fencing signal, and the harness asserts it.
30
+ --
31
+ -- WHERE THE SEMANTICS COME FROM, AND EVERY PLACE THIS KERNEL DEPARTS FROM THEM. The
32
+ -- behaviour above is derived from the managed chain's own claim/finish pair, read in
33
+ -- full rather than summarised: its two relations and its first begin/finish, the later
34
+ -- migration that added the second finish and the driver/lease/counter columns, the
35
+ -- migration that rewrote the claim into its current 199-line body, and the migration
36
+ -- that introduced a neutral trigger vocabulary. Five departures, each named because a
37
+ -- silent one is how a kernel and its origin quietly stop being the same machine:
38
+ --
39
+ -- 1. THE RUNNER VOCABULARY IS NEUTRAL: 'worker', 'scheduler', 'operator'. The chain's
40
+ -- set enumerates two named cron mechanisms, one of which is the scheduler of a
41
+ -- specific hosting vendor. Which scheduler a deployment happens to run is a fact
42
+ -- about that deployment, never a fact about a platform, and this kernel may not
43
+ -- carry a vendor's name in a CHECK constraint. The vocabulary chosen is not
44
+ -- invented here either: it is the one the chain's own newest control-plane
45
+ -- migration introduced, in its words, so that a deployment adapter is EVIDENCE
46
+ -- rather than an authorization decision. The concrete adapter label survives
47
+ -- losslessly in `p_metadata`, which is caller-supplied jsonb this kernel stores
48
+ -- and never interprets.
49
+ -- 2. THE FIRST CLAIMER DEFINES THE JOB'S ACTIVE RUNNER. Upstream the control row is
50
+ -- created with a fixed default runner, so on a fresh install the first tick of a
51
+ -- long-running worker is refused as the wrong runner until an operator edits a
52
+ -- row by hand. That is a migration artefact of a live cutover, not a platform
53
+ -- rule. Here the auto-created row adopts the runner that created it; changing
54
+ -- runners afterwards stays an explicit operator UPDATE, and the refusal below
55
+ -- still protects a job whose runner has been pinned.
56
+ -- 3. ONE LEASE COLUMN AND ONE COUNTER PAIR. Upstream carries `lease_until` and
57
+ -- `lease_expires_at` for one lease, and `checked`/`checked_count`,
58
+ -- `updated`/`updated_count` for one pair of counts, writing both of each on every
59
+ -- call. Those are compatibility shadows of an earlier signature. This kernel has
60
+ -- no earlier signature, so it ships one of each. For the same reason the run row
61
+ -- records its runner once, where upstream writes the same value into three
62
+ -- columns.
63
+ -- 4. NO `SECURITY DEFINER`, NO `GRANT`/`REVOKE`, NO ROW-LEVEL SECURITY, matching
64
+ -- every forward this catalogue already carries. This kernel creates zero roles.
65
+ -- Said out loud rather than assumed: ON THIS KERNEL THE FUNCTIONS BELOW ARE NOT A
66
+ -- SECURITY BOUNDARY -- the host application is what authenticates, and a caller
67
+ -- that can reach these functions can already reach the tables. `SET search_path`
68
+ -- is kept, because that one is a correctness property and not a role property.
69
+ -- 5. THE FINISH KEEPS ITS `_v2` SUFFIX. There is no v1 in this kernel and the suffix
70
+ -- describes the version history of a different codebase. It is kept anyway: it is
71
+ -- the name the shipped consumer calls, and giving a public kernel a second,
72
+ -- prettier name for one verb would be worse than carrying one inherited one.
73
+ --
74
+ -- WHAT THIS FORWARD DELIBERATELY DOES NOT SHIP, enumerated so nobody has to infer it:
75
+ -- * no runtime binding. Not one line of the application runtime is rewired onto this
76
+ -- ledger by this forward; the files that would do it belong to another line of work
77
+ -- in flight, and the falsifier for this kernel is its own parity harness driving
78
+ -- the shipped port's two calls;
79
+ -- * no job seed. A job NAME is a deployment fact, so this kernel names none. The
80
+ -- claim creates the control row it needs on first contact;
81
+ -- * no lease extension and no reaper. Neither exists upstream either. A lease that
82
+ -- elapses is simply claimable again, and a job that must not be re-entered before
83
+ -- its own work finishes must ask for a lease long enough to cover it;
84
+ -- * no attempt counter and no poison-pill policy. Upstream has none on this ledger,
85
+ -- and inventing one here would be a policy this kernel has no way to falsify;
86
+ -- * no scheduling. WHEN a job runs is the host's problem. This ledger only answers
87
+ -- whether an attempt that has already begun is allowed to proceed.
88
+
89
+ CREATE TABLE IF NOT EXISTS public.platform_job_controls (
90
+ job_name text PRIMARY KEY,
91
+ created_at timestamptz NOT NULL DEFAULT now(),
92
+ updated_at timestamptz NOT NULL DEFAULT now(),
93
+ enabled boolean NOT NULL DEFAULT true,
94
+ active_driver text NOT NULL
95
+ CHECK (active_driver IN ('worker', 'scheduler', 'operator')),
96
+ lease_owner text,
97
+ lease_token uuid,
98
+ lease_until timestamptz,
99
+ last_started_at timestamptz,
100
+ last_finished_at timestamptz,
101
+ last_success_at timestamptz,
102
+ last_status text
103
+ CHECK (last_status IS NULL OR last_status IN ('running', 'success', 'failed', 'skipped')),
104
+ last_run_id uuid,
105
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb
106
+ );
107
+
108
+ CREATE TABLE IF NOT EXISTS public.platform_job_runs (
109
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
110
+ job_name text NOT NULL REFERENCES public.platform_job_controls(job_name),
111
+ created_at timestamptz NOT NULL DEFAULT now(),
112
+ started_at timestamptz NOT NULL DEFAULT now(),
113
+ finished_at timestamptz,
114
+ status text NOT NULL DEFAULT 'running'
115
+ CHECK (status IN ('running', 'success', 'failed', 'skipped')),
116
+ driver text NOT NULL
117
+ CHECK (driver IN ('worker', 'scheduler', 'operator')),
118
+ lease_until timestamptz,
119
+ checked integer CHECK (checked IS NULL OR checked >= 0),
120
+ updated integer CHECK (updated IS NULL OR updated >= 0),
121
+ error text,
122
+ support_code text,
123
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb
124
+ );
125
+
126
+ CREATE INDEX IF NOT EXISTS idx_platform_job_runs_job_started
127
+ ON public.platform_job_runs (job_name, started_at DESC);
128
+
129
+ CREATE INDEX IF NOT EXISTS idx_platform_job_runs_status_started
130
+ ON public.platform_job_runs (status, started_at DESC);
131
+
132
+ COMMENT ON TABLE public.platform_job_controls IS
133
+ 'One row per scheduled job: whether it may run, which runner class owns it, and the live lease.';
134
+ COMMENT ON TABLE public.platform_job_runs IS
135
+ 'Append-only attempt ledger. A refused attempt is recorded as a skipped run, so a job that never runs is visible.';
136
+ COMMENT ON COLUMN public.platform_job_runs.driver IS
137
+ 'Neutral runner class of the attempt. The concrete adapter, if any, lives in metadata as evidence.';
138
+
139
+ CREATE OR REPLACE FUNCTION public.platform_claim_job_run(
140
+ p_job_name text,
141
+ p_driver text,
142
+ p_lease_seconds integer DEFAULT 900,
143
+ p_metadata jsonb DEFAULT '{}'::jsonb
144
+ )
145
+ RETURNS TABLE (
146
+ acquired boolean,
147
+ run_id uuid,
148
+ reason text,
149
+ lease_until timestamptz
150
+ )
151
+ LANGUAGE plpgsql
152
+ SET search_path = public, pg_catalog
153
+ AS $$
154
+ DECLARE
155
+ v_control public.platform_job_controls%ROWTYPE;
156
+ v_job_name text := btrim(COALESCE(p_job_name, ''));
157
+ v_driver text := COALESCE(NULLIF(btrim(p_driver), ''), 'worker');
158
+ v_lease_seconds integer;
159
+ v_run_id uuid;
160
+ v_now timestamptz := now();
161
+ v_metadata jsonb := COALESCE(p_metadata, '{}'::jsonb);
162
+ v_lease_until timestamptz;
163
+ v_refusal text;
164
+ BEGIN
165
+ IF v_job_name = '' THEN
166
+ RAISE EXCEPTION 'platform_job_name_required' USING ERRCODE = '22023';
167
+ END IF;
168
+
169
+ IF v_driver NOT IN ('worker', 'scheduler', 'operator') THEN
170
+ RAISE EXCEPTION 'platform_job_invalid_driver' USING ERRCODE = '22023';
171
+ END IF;
172
+
173
+ v_lease_seconds := LEAST(GREATEST(COALESCE(p_lease_seconds, 900), 60), 3600);
174
+
175
+ INSERT INTO public.platform_job_controls (job_name, active_driver)
176
+ VALUES (v_job_name, v_driver)
177
+ ON CONFLICT (job_name) DO NOTHING;
178
+
179
+ -- The row lock IS the exclusivity. Everything below runs with the control row held.
180
+ SELECT * INTO v_control
181
+ FROM public.platform_job_controls
182
+ WHERE public.platform_job_controls.job_name = v_job_name
183
+ FOR UPDATE;
184
+
185
+ v_lease_until := v_control.lease_until;
186
+ v_refusal := CASE
187
+ WHEN NOT v_control.enabled THEN 'job_disabled'
188
+ WHEN v_control.active_driver <> v_driver THEN 'inactive_driver'
189
+ WHEN v_lease_until IS NOT NULL AND v_lease_until > v_now THEN 'lease_active'
190
+ ELSE NULL
191
+ END;
192
+
193
+ IF v_refusal IS NOT NULL THEN
194
+ -- A refusal is still an attempt, and an attempt nobody records is an outage
195
+ -- nobody can see. The refused run carries WHY, and the control row records that
196
+ -- somebody tried; the live lease is left exactly as it was found.
197
+ INSERT INTO public.platform_job_runs (
198
+ job_name, status, driver, started_at, finished_at, lease_until, error, metadata
199
+ )
200
+ VALUES (
201
+ v_job_name, 'skipped', v_driver, v_now, v_now,
202
+ CASE WHEN v_refusal = 'lease_active' THEN v_lease_until ELSE NULL END,
203
+ v_refusal,
204
+ v_metadata || jsonb_build_object('activeDriver', v_control.active_driver, 'leaseOwner', v_control.lease_owner)
205
+ )
206
+ RETURNING id INTO v_run_id;
207
+
208
+ UPDATE public.platform_job_controls
209
+ SET updated_at = v_now,
210
+ last_started_at = v_now,
211
+ last_finished_at = v_now,
212
+ last_status = 'skipped',
213
+ last_run_id = v_run_id
214
+ WHERE public.platform_job_controls.job_name = v_job_name;
215
+
216
+ RETURN QUERY SELECT false, v_run_id, v_refusal,
217
+ CASE WHEN v_refusal = 'lease_active' THEN v_lease_until ELSE NULL::timestamptz END;
218
+ RETURN;
219
+ END IF;
220
+
221
+ v_run_id := gen_random_uuid();
222
+ v_lease_until := v_now + make_interval(secs => v_lease_seconds);
223
+
224
+ UPDATE public.platform_job_controls
225
+ SET updated_at = v_now,
226
+ lease_owner = v_driver,
227
+ lease_token = v_run_id,
228
+ lease_until = v_lease_until,
229
+ last_started_at = v_now,
230
+ last_status = 'running',
231
+ last_run_id = v_run_id
232
+ WHERE public.platform_job_controls.job_name = v_job_name;
233
+
234
+ INSERT INTO public.platform_job_runs (
235
+ id, job_name, status, driver, started_at, lease_until, metadata
236
+ )
237
+ VALUES (
238
+ v_run_id, v_job_name, 'running', v_driver, v_now, v_lease_until, v_metadata
239
+ );
240
+
241
+ RETURN QUERY SELECT true, v_run_id, 'acquired', v_lease_until;
242
+ END;
243
+ $$;
244
+
245
+ CREATE OR REPLACE FUNCTION public.platform_finish_job_run_v2(
246
+ p_job_name text,
247
+ p_run_id uuid,
248
+ p_status text,
249
+ p_checked integer DEFAULT NULL,
250
+ p_updated integer DEFAULT NULL,
251
+ p_error text DEFAULT NULL,
252
+ p_support_code text DEFAULT NULL,
253
+ p_metadata jsonb DEFAULT '{}'::jsonb
254
+ )
255
+ RETURNS boolean
256
+ LANGUAGE plpgsql
257
+ SET search_path = public, pg_catalog
258
+ AS $$
259
+ DECLARE
260
+ v_now timestamptz := now();
261
+ v_job_name text := btrim(COALESCE(p_job_name, ''));
262
+ v_metadata jsonb := COALESCE(p_metadata, '{}'::jsonb);
263
+ v_released boolean;
264
+ BEGIN
265
+ IF p_status IS NULL OR p_status NOT IN ('success', 'failed') THEN
266
+ RAISE EXCEPTION 'platform_job_invalid_finish_status' USING ERRCODE = '22023';
267
+ END IF;
268
+
269
+ -- The run row is always stamped, even by a runner whose lease is gone: a runner that
270
+ -- lost its lease still owes the ledger the truth about what it did. Deliberately NOT
271
+ -- guarded on `finished_at IS NULL` -- upstream re-stamps too, and a kernel that
272
+ -- silently ignored a second finish would answer a different last-writer than the
273
+ -- bundle it is compared against.
274
+ UPDATE public.platform_job_runs
275
+ SET status = p_status,
276
+ finished_at = v_now,
277
+ checked = p_checked,
278
+ updated = p_updated,
279
+ error = p_error,
280
+ support_code = p_support_code,
281
+ metadata = public.platform_job_runs.metadata || v_metadata
282
+ WHERE id = p_run_id
283
+ AND public.platform_job_runs.job_name = v_job_name;
284
+
285
+ -- The lease is released ONLY by the token that holds it. This predicate is the fence.
286
+ UPDATE public.platform_job_controls
287
+ SET updated_at = v_now,
288
+ lease_owner = NULL,
289
+ lease_token = NULL,
290
+ lease_until = NULL,
291
+ last_finished_at = v_now,
292
+ last_success_at = CASE WHEN p_status = 'success' THEN v_now ELSE last_success_at END,
293
+ last_status = p_status,
294
+ last_run_id = p_run_id
295
+ WHERE public.platform_job_controls.job_name = v_job_name
296
+ AND lease_token = p_run_id
297
+ RETURNING true INTO v_released;
298
+
299
+ RETURN COALESCE(v_released, false);
300
+ END;
301
+ $$;