@objectstack/runtime 9.9.1 → 9.10.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.
package/dist/index.d.cts CHANGED
@@ -230,30 +230,6 @@ declare class AppPlugin implements Plugin {
230
230
  init: (ctx: PluginContext) => Promise<void>;
231
231
  start: (ctx: PluginContext) => Promise<void>;
232
232
  stop: (ctx: PluginContext) => Promise<void>;
233
- /**
234
- * Resolve the identity bound to `os.user` / `os.org` for seed CEL values.
235
- *
236
- * On a fresh boot there are zero users until the first human sign-up
237
- * (which the SeedLoader runs *before*), so identity-derived seeds like
238
- * `owner_id: cel`os.user.id`` had nothing to resolve against and were
239
- * dropped silently. To make seeds deterministic and self-sufficient we
240
- * upsert a single non-loginable **system user** (`usr_system`) and bind
241
- * it as `os.user`.
242
- *
243
- * Why a dedicated system user rather than the login admin:
244
- * - `sys_user` is better-auth-managed and schema-locked (ADR-0010); the
245
- * password lives in `sys_account`, so a *loginable* admin can only be
246
- * minted through better-auth (the CLI does this via HTTP sign-up after
247
- * boot). A raw insert here would bypass those invariants.
248
- * - `usr_system` is an owner identity only (no credential row), analogous
249
- * to Salesforce's "Automated Process" user. The human admin is created
250
- * independently and need not be the seed owner.
251
- *
252
- * Idempotent: matches by the stable id, inserts once, reuses thereafter.
253
- * Failures are non-fatal (logged) — records that actually need `os.user`
254
- * then fail loudly in the loader with an actionable message.
255
- */
256
- private ensureSeedIdentity;
257
233
  /**
258
234
  * Emit a kernel hook so the control-plane `AppCatalogService` can
259
235
  * upsert / delete the corresponding `sys_app` row. Silently no-ops
package/dist/index.d.ts CHANGED
@@ -230,30 +230,6 @@ declare class AppPlugin implements Plugin {
230
230
  init: (ctx: PluginContext) => Promise<void>;
231
231
  start: (ctx: PluginContext) => Promise<void>;
232
232
  stop: (ctx: PluginContext) => Promise<void>;
233
- /**
234
- * Resolve the identity bound to `os.user` / `os.org` for seed CEL values.
235
- *
236
- * On a fresh boot there are zero users until the first human sign-up
237
- * (which the SeedLoader runs *before*), so identity-derived seeds like
238
- * `owner_id: cel`os.user.id`` had nothing to resolve against and were
239
- * dropped silently. To make seeds deterministic and self-sufficient we
240
- * upsert a single non-loginable **system user** (`usr_system`) and bind
241
- * it as `os.user`.
242
- *
243
- * Why a dedicated system user rather than the login admin:
244
- * - `sys_user` is better-auth-managed and schema-locked (ADR-0010); the
245
- * password lives in `sys_account`, so a *loginable* admin can only be
246
- * minted through better-auth (the CLI does this via HTTP sign-up after
247
- * boot). A raw insert here would bypass those invariants.
248
- * - `usr_system` is an owner identity only (no credential row), analogous
249
- * to Salesforce's "Automated Process" user. The human admin is created
250
- * independently and need not be the seed owner.
251
- *
252
- * Idempotent: matches by the stable id, inserts once, reuses thereafter.
253
- * Failures are non-fatal (logged) — records that actually need `os.user`
254
- * then fail loudly in the loader with an actionable message.
255
- */
256
- private ensureSeedIdentity;
257
233
  /**
258
234
  * Emit a kernel hook so the control-plane `AppCatalogService` can
259
235
  * upsert / delete the corresponding `sys_app` row. Silently no-ops
package/dist/index.js CHANGED
@@ -205,7 +205,7 @@ var init_package_state_store = __esm({
205
205
  import {
206
206
  newAsyncContext
207
207
  } from "quickjs-emscripten";
208
- function installApiMethod(vm, parent, method, objectName, ctx, caps, required, origin) {
208
+ function installApiMethod(vm, parent, method, objectName, ctx, caps, required, origin, txState) {
209
209
  const fn = vm.newFunction(method, (...argHandles) => {
210
210
  if (!caps.has(required)) {
211
211
  throw new SandboxError(
@@ -220,7 +220,8 @@ function installApiMethod(vm, parent, method, objectName, ctx, caps, required, o
220
220
  const deferred = vm.newPromise();
221
221
  void (async () => {
222
222
  try {
223
- const proxy = apiAny.object(objectName);
223
+ const source = txState.api ?? apiAny;
224
+ const proxy = source.object(objectName);
224
225
  const m = proxy[method];
225
226
  if (typeof m !== "function") {
226
227
  throw new SandboxError(`ctx.api.object('${objectName}').${method} not implemented`);
@@ -365,8 +366,9 @@ var init_quickjs_runner = __esm({
365
366
  const start = Date.now();
366
367
  const deadline = start + args.timeoutMs;
367
368
  runtime.setInterruptHandler(() => Date.now() > deadline);
369
+ const txState = { api: null, handle: null, open: false };
368
370
  try {
369
- this.installCtx(vm, args.ctx, new Set(args.capabilities), args.origin);
371
+ this.installCtx(vm, args.ctx, new Set(args.capabilities), args.origin, txState);
370
372
  if (args.isExpression) {
371
373
  const wrapped2 = `globalThis.__result = JSON.stringify((function(){ return (${args.source}); })());`;
372
374
  const result = vm.evalCode(wrapped2);
@@ -429,6 +431,16 @@ var init_quickjs_runner = __esm({
429
431
  pumps++;
430
432
  }
431
433
  } finally {
434
+ if (txState.open && txState.handle != null) {
435
+ const apiTx = args.ctx.api;
436
+ const rollback = apiTx?.rollbackTransaction;
437
+ if (typeof rollback === "function") {
438
+ try {
439
+ await rollback.call(apiTx, txState.handle);
440
+ } catch {
441
+ }
442
+ }
443
+ }
432
444
  vm.dispose();
433
445
  }
434
446
  }
@@ -441,7 +453,7 @@ var init_quickjs_runner = __esm({
441
453
  * {@link installApiMethod}) so they may return Promises (real ObjectQL
442
454
  * `find/count/insert/...` are async) without asyncify's single-unwind limit.
443
455
  */
444
- installCtx(vm, ctx, caps, origin) {
456
+ installCtx(vm, ctx, caps, origin, txState) {
445
457
  setGlobalJson(vm, "__input", ctx.input);
446
458
  setGlobalJson(vm, "__previous", ctx.previous);
447
459
  const ctxObj = vm.newObject();
@@ -476,12 +488,70 @@ var init_quickjs_runner = __esm({
476
488
  const wrap = vm.newObject();
477
489
  const READ = ["find", "findOne", "count", "aggregate"];
478
490
  const WRITE = ["insert", "update", "delete", "updateMany", "deleteMany", "upsert"];
479
- for (const m of READ) installApiMethod(vm, wrap, m, objectName, ctx, caps, "api.read", origin);
480
- for (const m of WRITE) installApiMethod(vm, wrap, m, objectName, ctx, caps, "api.write", origin);
491
+ for (const m of READ) installApiMethod(vm, wrap, m, objectName, ctx, caps, "api.read", origin, txState);
492
+ for (const m of WRITE) installApiMethod(vm, wrap, m, objectName, ctx, caps, "api.write", origin, txState);
481
493
  return wrap;
482
494
  });
483
495
  vm.setProp(apiObj, "object", objectFn);
484
496
  objectFn.dispose();
497
+ const apiTx = ctx.api;
498
+ const installTxLeaf = (name, run) => {
499
+ const fn = vm.newFunction(name, () => {
500
+ if (!caps.has("api.transaction")) {
501
+ throw new SandboxError(
502
+ `capability 'api.transaction' not granted to ${origin.kind} '${origin.name}' (called ctx.api.transaction)`
503
+ );
504
+ }
505
+ const deferred = vm.newPromise();
506
+ void (async () => {
507
+ try {
508
+ await run();
509
+ if (!vm.alive) return;
510
+ deferred.resolve(vm.undefined);
511
+ } catch (err) {
512
+ if (!vm.alive) return;
513
+ const errH = err instanceof Error ? vm.newError({ name: err.name || "Error", message: err.message }) : vm.newError({ name: "Error", message: String(err) });
514
+ deferred.reject(errH);
515
+ errH.dispose();
516
+ }
517
+ })();
518
+ return deferred.handle;
519
+ });
520
+ vm.setProp(apiObj, name, fn);
521
+ fn.dispose();
522
+ };
523
+ installTxLeaf("__txBegin", async () => {
524
+ if (txState.open) throw new SandboxError("nested ctx.api.transaction is not supported");
525
+ const begin = apiTx?.beginTransaction;
526
+ if (typeof begin === "function") {
527
+ const r = await begin.call(apiTx) ?? null;
528
+ if (r) {
529
+ txState.api = r.ctx;
530
+ txState.handle = r.handle;
531
+ }
532
+ }
533
+ txState.open = true;
534
+ });
535
+ installTxLeaf("__txCommit", async () => {
536
+ const { handle, open } = txState;
537
+ txState.api = null;
538
+ txState.handle = null;
539
+ txState.open = false;
540
+ const commit = apiTx?.commitTransaction;
541
+ if (open && handle != null && typeof commit === "function") {
542
+ await commit.call(apiTx, handle);
543
+ }
544
+ });
545
+ installTxLeaf("__txRollback", async () => {
546
+ const { handle, open } = txState;
547
+ txState.api = null;
548
+ txState.handle = null;
549
+ txState.open = false;
550
+ const rollback = apiTx?.rollbackTransaction;
551
+ if (open && handle != null && typeof rollback === "function") {
552
+ await rollback.call(apiTx, handle);
553
+ }
554
+ });
485
555
  vm.setProp(ctxObj, "api", apiObj);
486
556
  apiObj.dispose();
487
557
  const logObj = vm.newObject();
@@ -514,6 +584,25 @@ var init_quickjs_runner = __esm({
514
584
  cryptoObj.dispose();
515
585
  vm.setProp(vm.global, "__ctx", ctxObj);
516
586
  ctxObj.dispose();
587
+ const sugar = vm.evalCode(
588
+ `__ctx.api.transaction = async function (fn) {
589
+ await __ctx.api.__txBegin();
590
+ try {
591
+ var r = await fn();
592
+ await __ctx.api.__txCommit();
593
+ return r;
594
+ } catch (e) {
595
+ await __ctx.api.__txRollback();
596
+ throw e;
597
+ }
598
+ };`
599
+ );
600
+ if (sugar.error) {
601
+ const msg = vm.dump(sugar.error);
602
+ sugar.error.dispose();
603
+ throw new SandboxError(`failed to install ctx.api.transaction: ${formatErr(msg)}`);
604
+ }
605
+ sugar.value.dispose();
517
606
  }
518
607
  };
519
608
  SandboxError = class extends Error {
@@ -714,7 +803,6 @@ __export(app_plugin_exports, {
714
803
  collectBundleHooks: () => collectBundleHooks
715
804
  });
716
805
  import { readEnvWithDeprecation } from "@objectstack/types";
717
- import { SystemUserId } from "@objectstack/spec/system";
718
806
  function collectBundleHooks(bundle) {
719
807
  const out = [];
720
808
  const seen = /* @__PURE__ */ new Set();
@@ -1045,7 +1133,7 @@ var init_app_plugin = __esm({
1045
1133
  ...d,
1046
1134
  object: d.object
1047
1135
  }));
1048
- const seedIdentity = await this.ensureSeedIdentity(ql, ctx.logger);
1136
+ const seedIdentity = void 0;
1049
1137
  try {
1050
1138
  const kernel = ctx.kernel;
1051
1139
  const existing = (() => {
@@ -1088,10 +1176,10 @@ var init_app_plugin = __esm({
1088
1176
  defaultMode: "upsert",
1089
1177
  multiPass: true,
1090
1178
  organizationId,
1091
- // Bind os.user (system identity) and os.org (this
1092
- // tenant) so identity-derived seed values resolve
1093
- // per-org. org.id falls back to organizationId
1094
- // inside the loader when identity.org is absent.
1179
+ // `os.org` is derived from organizationId inside
1180
+ // the loader. `seedIdentity` (os.user) is undefined
1181
+ // unless a seed embeds `cel`os.user.id`` see the
1182
+ // lazy guard where it is resolved.
1095
1183
  identity: seedIdentity
1096
1184
  }
1097
1185
  });
@@ -1251,64 +1339,6 @@ var init_app_plugin = __esm({
1251
1339
  this.name = `plugin.app.${appId}`;
1252
1340
  this.version = sys?.version;
1253
1341
  }
1254
- /**
1255
- * Resolve the identity bound to `os.user` / `os.org` for seed CEL values.
1256
- *
1257
- * On a fresh boot there are zero users until the first human sign-up
1258
- * (which the SeedLoader runs *before*), so identity-derived seeds like
1259
- * `owner_id: cel`os.user.id`` had nothing to resolve against and were
1260
- * dropped silently. To make seeds deterministic and self-sufficient we
1261
- * upsert a single non-loginable **system user** (`usr_system`) and bind
1262
- * it as `os.user`.
1263
- *
1264
- * Why a dedicated system user rather than the login admin:
1265
- * - `sys_user` is better-auth-managed and schema-locked (ADR-0010); the
1266
- * password lives in `sys_account`, so a *loginable* admin can only be
1267
- * minted through better-auth (the CLI does this via HTTP sign-up after
1268
- * boot). A raw insert here would bypass those invariants.
1269
- * - `usr_system` is an owner identity only (no credential row), analogous
1270
- * to Salesforce's "Automated Process" user. The human admin is created
1271
- * independently and need not be the seed owner.
1272
- *
1273
- * Idempotent: matches by the stable id, inserts once, reuses thereafter.
1274
- * Failures are non-fatal (logged) — records that actually need `os.user`
1275
- * then fail loudly in the loader with an actionable message.
1276
- */
1277
- async ensureSeedIdentity(ql, logger) {
1278
- const SYSTEM_USER_ID = SystemUserId.SYSTEM;
1279
- const SYSTEM_USER_EMAIL = "system@objectstack.local";
1280
- const identity = { user: { id: SYSTEM_USER_ID, role: "system", email: SYSTEM_USER_EMAIL } };
1281
- const opts = { context: { isSystem: true } };
1282
- try {
1283
- const existing = await ql.find(
1284
- "sys_user",
1285
- { where: { id: SYSTEM_USER_ID }, limit: 1 },
1286
- opts
1287
- );
1288
- if (Array.isArray(existing) && existing.length > 0) {
1289
- return identity;
1290
- }
1291
- await ql.insert(
1292
- "sys_user",
1293
- {
1294
- id: SYSTEM_USER_ID,
1295
- name: "System",
1296
- email: SYSTEM_USER_EMAIL,
1297
- email_verified: true,
1298
- role: "system"
1299
- },
1300
- opts
1301
- );
1302
- logger.info(
1303
- `[Seeder] Provisioned deterministic system user (${SYSTEM_USER_ID}) as seed owner \u2014 binds os.user for identity-derived seed values`
1304
- );
1305
- } catch (err) {
1306
- logger.warn("[Seeder] Failed to ensure system seed user; os.user-dependent seeds may be dropped", {
1307
- error: err?.message ?? String(err)
1308
- });
1309
- }
1310
- return identity;
1311
- }
1312
1342
  /**
1313
1343
  * Emit a kernel hook so the control-plane `AppCatalogService` can
1314
1344
  * upsert / delete the corresponding `sys_app` row. Silently no-ops