@fabrika/provider-zerops 0.0.12 → 0.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/provider-zerops",
3
- "version": "0.0.12",
3
+ "version": "0.0.13",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -32,9 +32,9 @@
32
32
  "gen:schema": "bun scripts/generate-zerops-types.ts"
33
33
  },
34
34
  "dependencies": {
35
- "@fabrika/auth": "^0.0.12",
36
- "@fabrika/auth-core": "^0.0.12",
37
- "@fabrika/provider-contract": "^0.0.12",
38
- "@fabrika/proxy-contract": "^0.0.12"
35
+ "@fabrika/auth": "^0.0.13",
36
+ "@fabrika/auth-core": "^0.0.13",
37
+ "@fabrika/provider-contract": "^0.0.13",
38
+ "@fabrika/proxy-contract": "^0.0.13"
39
39
  }
40
40
  }
package/src/api.ts CHANGED
@@ -446,6 +446,12 @@ export interface ZeropsApi {
446
446
  listProjectServices(input: { projectId: string; signal: AbortSignal }): Promise<ZeropsService[]>
447
447
 
448
448
  // ── user-data: SERVICE-level environment variables (ADR-0004) ─────────────────
449
+ //
450
+ // EVERY WRITE HERE IS ASYNCHRONOUS. Create, replace and delete each answer with a PENDING
451
+ // `stack.updateUserData` process rather than the record, and while one is running the platform
452
+ // refuses the next operation on that service with `400 userDataSyncRunning`. So all three wait for
453
+ // their own process before returning: a caller that writes a variable and then builds — which is
454
+ // what every deploy does — must not have to know that.
449
455
 
450
456
  /**
451
457
  * Every environment variable of ONE service, each with the record id `deleteServiceEnv` takes.
@@ -576,6 +582,8 @@ export interface ZeropsApiOptions {
576
582
  /** Override for a different region's API host. */
577
583
  baseUrl?: string
578
584
  fetchImpl?: FetchLike
585
+ /** How the user-data writes wait for their platform process. Tests pass a sleeper that does not sleep. */
586
+ sleep?: Sleeper
579
587
  }
580
588
 
581
589
  /** Read a property off an unknown value without asserting anything about the value's shape. */
@@ -797,10 +805,29 @@ const apiError = (label: string, status: number, body: unknown, redactDetail: bo
797
805
  return new ZeropsApiError(`zerops: ${label} failed (${status})${detail === '' ? '' : ` — ${detail.slice(0, 300)}`}`, status, code)
798
806
  }
799
807
 
808
+ const timerSleep: Sleeper = (ms, signal) =>
809
+ new Promise<void>((resolve) => {
810
+ if (signal.aborted) {
811
+ resolve()
812
+ return
813
+ }
814
+ const timer = setTimeout(() => {
815
+ signal.removeEventListener('abort', onAbort)
816
+ resolve()
817
+ }, ms)
818
+ const onAbort = (): void => {
819
+ clearTimeout(timer)
820
+ resolve()
821
+ }
822
+ signal.addEventListener('abort', onAbort, { once: true })
823
+ })
824
+
800
825
  /** Build the real client. Every request carries the bearer and the run's signal. */
801
826
  export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
802
827
  const base = (options.baseUrl ?? ZEROPS_API_BASE).replace(/\/+$/, '')
803
828
  const doFetch = options.fetchImpl ?? fetch
829
+ // Its own, rather than `collaborators`' — that module imports this one, so taking the value back would close a cycle.
830
+ const sleep = options.sleep ?? timerSleep
804
831
  const abortError = (): DOMException => new DOMException('The operation was aborted', 'AbortError')
805
832
  const isAbortError = (error: unknown): boolean => error instanceof Error && error.name === 'AbortError'
806
833
  const SERVICE_ENV_RESPONSE_MAX_BYTES = 8 * 1024 * 1024
@@ -975,6 +1002,30 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
975
1002
  return items.map(readServiceEnv)
976
1003
  }
977
1004
 
1005
+ const getProcess: ZeropsApi['getProcess'] = async ({ processId, signal }) =>
1006
+ readProcess(await request('get process', 'GET', `/process/${processId}`, { signal }))
1007
+
1008
+ /**
1009
+ * Wait out the `stack.updateUserData` process every user-data write answers with.
1010
+ *
1011
+ * While one runs the platform refuses the NEXT operation on that service with `400
1012
+ * userDataSyncRunning` — which is how a deploy that wrote an app's environment and then asked for a
1013
+ * build failed, one second before the sync it had started finished. Measured live: PENDING → RUNNING →
1014
+ * FINISHED in about three seconds, on a deployed service and on one that has never had code alike.
1015
+ *
1016
+ * The response is recognised by `actionName`, not by having an `id`: a user-data RECORD carries an id
1017
+ * too, and polling `/process/{recordId}` would hang instead of failing. An unrecognised response is
1018
+ * left alone — nothing then says the write is still settling.
1019
+ */
1020
+ const awaitUserDataSync = async (payload: unknown, label: string, signal: AbortSignal): Promise<void> => {
1021
+ const processId = str(payload, 'id')
1022
+ const action = str(payload, 'actionName')
1023
+ if (processId === undefined || processId.trim() === '' || action === undefined || action.trim() === '') {
1024
+ return
1025
+ }
1026
+ await waitForProcess({ api: { getProcess }, processId, sleep, signal, label })
1027
+ }
1028
+
978
1029
  return {
979
1030
  importServices: async ({ projectId, yaml, signal }) =>
980
1031
  readImportResult(await request('service-stack import', 'POST', `/project/${projectId}/service-stack/import`, { body: { yaml }, signal })),
@@ -1017,10 +1068,13 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
1017
1068
 
1018
1069
  buildAndDeployAppVersion: async ({ appVersionId, zeropsYaml, zeropsYamlSetup, signal }) => {
1019
1070
  const body = zeropsYamlSetup === undefined ? { zeropsYaml } : { zeropsYaml, zeropsYamlSetup }
1071
+ // `redactDetail`, not `omitErrorResponseDetails`: this request body is the app's COMMITTED
1072
+ // descriptor and this response carries no upload URL, so there is nothing here to protect by
1073
+ // dropping the code as well — and a bare `(400)` is what made `userDataSyncRunning` cost a day.
1020
1074
  const payload = await request('build and deploy app-version', 'PUT', `/app-version/${appVersionId}/build-and-deploy`, {
1021
1075
  body,
1022
1076
  signal,
1023
- omitErrorResponseDetails: true,
1077
+ redactDetail: true,
1024
1078
  })
1025
1079
  const processId = requiredString(payload, 'id', 'build and deploy app-version')
1026
1080
  const responseAppVersionId = str(prop(payload, 'appVersion'), 'id')
@@ -1057,7 +1111,7 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
1057
1111
  await request('cancel-build', 'PUT', `/app-version/${appVersionId}/cancel-build`, { signal })
1058
1112
  },
1059
1113
 
1060
- getProcess: async ({ processId, signal }) => readProcess(await request('get process', 'GET', `/process/${processId}`, { signal })),
1114
+ getProcess,
1061
1115
 
1062
1116
  enableSubdomainAccess: async ({ serviceId, signal }) => {
1063
1117
  // No body: the operation takes none, and sending one would add a content-type the platform did not ask for.
@@ -1101,8 +1155,9 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
1101
1155
  listServiceEnv,
1102
1156
 
1103
1157
  createServiceEnv: async ({ serviceId, key, value, signal }) => {
1158
+ let started: unknown
1104
1159
  try {
1105
- await request('create-only service env', 'POST', `/service-stack/${serviceId}/user-data`, {
1160
+ started = await request('create-only service env', 'POST', `/service-stack/${serviceId}/user-data`, {
1106
1161
  body: { key, content: value, sensitive: SENSITIVE_USER_DATA },
1107
1162
  signal,
1108
1163
  omitErrorResponseDetails: true,
@@ -1111,16 +1166,19 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
1111
1166
  if (error instanceof Error && error.name === 'AbortError') throw error
1112
1167
  throw new Error('zerops: create-only service env failed')
1113
1168
  }
1169
+ await awaitUserDataSync(started, `the user-data sync on service ${serviceId}`, signal)
1114
1170
  },
1115
1171
 
1116
1172
  putServiceEnv: async ({ serviceId, key, value, signal }) => {
1173
+ const label = `the user-data sync on service ${serviceId}`
1117
1174
  // `redactDetail` on both writes: this is the one place a secret VALUE is in the request body.
1118
1175
  try {
1119
- await request('create service env', 'POST', `/service-stack/${serviceId}/user-data`, {
1176
+ const created = await request('create service env', 'POST', `/service-stack/${serviceId}/user-data`, {
1120
1177
  body: { key, content: value, sensitive: SENSITIVE_USER_DATA },
1121
1178
  signal,
1122
1179
  redactDetail: true,
1123
1180
  })
1181
+ await awaitUserDataSync(created, label, signal)
1124
1182
  return
1125
1183
  } catch (error) {
1126
1184
  if (!(error instanceof ZeropsApiError) || error.code !== USER_DATA_DUPLICATE_KEY) {
@@ -1137,15 +1195,17 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
1137
1195
  `zerops: service ${serviceId} already defines \`${key}\` outside the environment API — it cannot be written`,
1138
1196
  )
1139
1197
  }
1140
- await request('update service env', 'PUT', `/user-data/${existing.id}`, {
1198
+ const updated = await request('update service env', 'PUT', `/user-data/${existing.id}`, {
1141
1199
  body: { key, content: value, sensitive: SENSITIVE_USER_DATA },
1142
1200
  signal,
1143
1201
  redactDetail: true,
1144
1202
  })
1203
+ await awaitUserDataSync(updated, label, signal)
1145
1204
  },
1146
1205
 
1147
1206
  deleteServiceEnv: async ({ envId, signal }) => {
1148
- await request('delete service env', 'DELETE', `/user-data/${envId}`, { signal })
1207
+ const deleted = await request('delete service env', 'DELETE', `/user-data/${envId}`, { signal })
1208
+ await awaitUserDataSync(deleted, `the user-data sync for record ${envId}`, signal)
1149
1209
  },
1150
1210
 
1151
1211
  getProjectEnv: async ({ projectEnvId, signal }) =>
package/src/manifest.ts CHANGED
@@ -17,7 +17,7 @@ const SHA256_PATTERN = /^[0-9a-f]{64}$/
17
17
  * asserts the two never diverge.
18
18
  */
19
19
  const SHARED_POSTGRES_CONNECTION_STRING: ZeropsSharedPostgresBinding['connectionString'] =
20
- '${postgres_connectionString}/${postgres_dbName}?sslmode=require'
20
+ '${postgres_connectionTlsString}/${postgres_dbName}?sslmode=require'
21
21
 
22
22
  export interface FabrikaImportDocument {
23
23
  readonly project?: { readonly [key: string]: JsonValue }
package/src/namespace.ts CHANGED
@@ -34,21 +34,24 @@ export const ZEROPS_NAMESPACE_POSTGRES_HOSTNAME = 'postgres'
34
34
  export const ZEROPS_NAMESPACE_IAM_URL_VARIABLE = 'FABRIKA_IAM_URL'
35
35
  export const ZEROPS_NAMESPACE_IAM_KEY_VARIABLE = 'FABRIKA_IAM_KEY'
36
36
  /**
37
- * How an app reaches the namespace-owned PostgreSQL service — database and TLS mode NAMED, not defaulted.
37
+ * How an app reaches the namespace-owned PostgreSQL service — port, database and TLS mode NAMED, not
38
+ * defaulted. Every part settled against a live account, none of it safe to infer:
38
39
  *
39
- * Three facts settled against a live account, none of them safe to infer:
40
- *
41
- * • `${x_connectionString}` is `postgresql://${user}:${password}@${hostname}:${port}` — no database
42
- * path. With none, the libpq/postgres.js convention falls back to the USER name.
40
+ * • `${x_connectionTlsString}` is `postgresql://${user}:${password}@${hostname}:${portTls}` — no
41
+ * database path. With none, the libpq/postgres.js convention falls back to the USER name.
43
42
  * • `dbName` and `user` are both literally `db` on EVERY PostgreSQL service, whatever its hostname
44
43
  * (checked on a service named `wu2db`). So the fallback lands right today — by construction rather
45
44
  * than by luck, but still by a rule nobody wrote down. `/${x_dbName}` says it out loud.
46
- * • Port 5432 speaks TLS. `sslmode=require` connects with `pg_stat_ssl.ssl = true`; `sslmode=disable`
47
- * connects in the clear. `verify-full` fails (`self signed certificate`), so `require` is the
48
- * strongest mode available and the one worth pinning — the alternative is whatever the driver
49
- * happens to default to.
45
+ * • THE TLS PORT, NOT THE DIRECT ONE, and this is the one that changed. `postgresql:single@18`
46
+ * speaks TLS on both; `postgresql:ha@18` speaks it ONLY on `portTls` and answers `sslmode=require`
47
+ * on the direct port with "Server does not support SSL". A namespace picks `ha` for `prod` and
48
+ * `single` everywhere else, so a DSN naming the direct port is one preset away from failing.
49
+ * • `portTls` preserves SESSION state on both — same backend pid across statements, `SET SESSION`
50
+ * kept, advisory lock held and visible — so the migration runner's session-level lock is safe
51
+ * there. It is a TLS endpoint, not the transaction pool the port number suggests.
52
+ * • `verify-full` fails (`self signed certificate`), so `require` is the strongest mode available.
50
53
  */
51
- export const ZEROPS_SHARED_POSTGRES_CONNECTION_STRING = '${postgres_connectionString}/${postgres_dbName}?sslmode=require'
54
+ export const ZEROPS_SHARED_POSTGRES_CONNECTION_STRING = '${postgres_connectionTlsString}/${postgres_dbName}?sslmode=require'
52
55
 
53
56
  const PROXY_TYPE = 'alpine@3.21'
54
57
  const EMPTY_PROXY_MANIFEST = encodeProxyManifestJson({ apps: [] })
package/src/types.ts CHANGED
@@ -95,7 +95,7 @@ export interface ZeropsProxySpec {
95
95
  export interface ZeropsSharedPostgresBinding {
96
96
  readonly resourceKey: 'service:postgres'
97
97
  readonly hostname: 'postgres'
98
- readonly connectionString: '${postgres_connectionString}/${postgres_dbName}?sslmode=require'
98
+ readonly connectionString: '${postgres_connectionTlsString}/${postgres_dbName}?sslmode=require'
99
99
  }
100
100
 
101
101
  export type ZeropsNamespaceResourceRequirement = ZeropsSharedPostgresBinding