@substrat-run/contract-tests 0.120.0 → 0.122.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.
@@ -1127,6 +1127,42 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
1127
1127
  await expect(host.admin.markEventsDrained(staff, t2, sDrain, [])).rejects.toThrow(/unknown scope/);
1128
1128
  await expect(host.admin.markEventsDrained(staff, t1, scopeId.parse(ulid()), [])).rejects.toThrow(/unknown scope/);
1129
1129
  });
1130
+ it('stamps and reopens more events than a Durable Object binds parameters (#1776)', async () => {
1131
+ // A DO's SQLite refuses the 101st bound parameter (#1741), and the drain batch is 200
1132
+ // by default. A statement binding one `?` per event id therefore failed every default
1133
+ // drain of a busy scope on the hosted host, while node, which allows far more, stayed
1134
+ // green. Its own scope, so the counts below are this test's events and nothing else.
1135
+ const sBig = scopeId.parse(ulid());
1136
+ await host.provisionScope(staff, { tenantId: t1, scopeId: sBig, vertical: 'connector-vertical' });
1137
+ await host.admin.activateScope(staff, t1, sBig);
1138
+ const stub = await host.getScope(alice, t1, sBig);
1139
+ await stub.invoke('test/emit-burst', { entityId: ulid(), count: 350 });
1140
+ const pending = await host.admin.readUndrainedEvents(staff, t1, sBig, 1000);
1141
+ expect(pending).toHaveLength(350);
1142
+ const ids = pending.map((e) => e.id);
1143
+ // 150: past the limit. Then 200, the default batch, overlapping the first so the
1144
+ // count has to tell a stamped row from an unstamped one inside one oversized list.
1145
+ await expect(host.admin.markEventsDrained(staff, t1, sBig, ids.slice(0, 150))).resolves.toBe(150);
1146
+ // The two batches must carry different instants for the reopen below to tell them apart.
1147
+ const firstMarked = Date.now();
1148
+ while (Date.now() <= firstMarked + 1)
1149
+ await new Promise((r) => setTimeout(r, 1));
1150
+ await expect(host.admin.markEventsDrained(staff, t1, sBig, ids.slice(100, 350))).resolves.toBe(200);
1151
+ await expect(host.admin.readUndrainedEvents(staff, t1, sBig, 1000)).resolves.toHaveLength(0);
1152
+ const stamps = (await host.admin.auditLog(staff, { tenantId: t1 }))
1153
+ .filter((r) => r.action === 'drainEvents' && r.scopeId === sBig)
1154
+ .map((r) => r.after)
1155
+ .sort((a, b) => (a.drainedAt < b.drainedAt ? -1 : 1));
1156
+ expect(stamps.map((s) => [s.drained, s.requested])).toEqual([[150, 150], [200, 250]]);
1157
+ // The reopen, past the limit the same way. Just after the FIRST instant it reopens exactly
1158
+ // the first 150, which is what shows the overlap kept its first stamp: a stamp that
1159
+ // re-marked rows 100–149 would have moved them out of this window.
1160
+ const justAfter = (at) => new Date(Date.parse(at) + 1).toISOString();
1161
+ await expect(host.admin.redrainEvents(staff, t1, sBig, { drainedBefore: justAfter(stamps[0].drainedAt) })).resolves.toBe(150);
1162
+ expect((await host.admin.readUndrainedEvents(staff, t1, sBig, 1000)).map((e) => e.id)).toEqual(ids.slice(0, 150));
1163
+ await expect(host.admin.redrainEvents(staff, t1, sBig, { drainedBefore: justAfter(stamps[1].drainedAt) })).resolves.toBe(200);
1164
+ expect((await host.admin.readUndrainedEvents(staff, t1, sBig, 1000)).map((e) => e.id)).toEqual(ids);
1165
+ });
1130
1166
  it('facets the outbox, and keeps an ERASED payload out of the null bucket (#1239)', async () => {
1131
1167
  const stub = await host.getScope(alice, t1, s1);
1132
1168
  const subject = ulid();
@@ -4165,6 +4201,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
4165
4201
  await expectRefusal(host.admin.promoteVersion(staff, 'callout', 'prod', ghost), 'not_found');
4166
4202
  await expectRefusal(host.admin.bindScopeVersion(staff, t1, s1, ghost), 'not_found');
4167
4203
  await expectRefusal(host.admin.versionManifest(staff, 'callout', ghost), 'not_found');
4204
+ await expectRefusal(host.admin.versionMigrations(staff, 'callout', ghost), 'not_found');
4168
4205
  // versionManifest refuses on the PAIR — `!v || v.vertical_slug !== verticalSlug` —
4169
4206
  // so the absent id above only exercises HALF its guard. A version that really
4170
4207
  // exists, under a DIFFERENT vertical, must read as absent too: asking for it under
@@ -4191,6 +4228,56 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
4191
4228
  // firing, not the version failing to exist.
4192
4229
  await host.admin.versionManifest(staff, 'otherowner', elsewhere);
4193
4230
  await expectRefusal(host.admin.versionManifest(staff, 'callout', elsewhere), 'not_found');
4231
+ // The migrations read refuses on the same pair: a version's SQL describes its schema.
4232
+ await host.admin.versionMigrations(staff, 'otherowner', elsewhere);
4233
+ await expectRefusal(host.admin.versionMigrations(staff, 'callout', elsewhere), 'not_found');
4234
+ });
4235
+ it('stores a version\'s SQL migrations apart from its manifest, and never reads absent as none (#1764)', async () => {
4236
+ await host.admin.registerVertical(staff, { slug: 'migrationsapart', name: 'Apart', source: 'builtin' });
4237
+ const base = {
4238
+ version: '1.0.0',
4239
+ entry: 'index.js',
4240
+ registry: { permissions: [], roles: [], entityGrants: [] },
4241
+ digests: { manifest: 'm', permission: 'p', migration: 'g' },
4242
+ };
4243
+ const publish = async (manifest) => {
4244
+ const id = ulid();
4245
+ await host.admin.publishVersion(staff, {
4246
+ id, verticalSlug: 'migrationsapart', version: `1.0.${id.toLowerCase()}`, manifestDigest: 'm',
4247
+ permissionDigest: 'p', migrationDigest: 'g', deploymentRef: null,
4248
+ manifestJson: typeof manifest === 'string' ? manifest : JSON.stringify(manifest),
4249
+ });
4250
+ return id;
4251
+ };
4252
+ const migrations = [
4253
+ { moduleId: 'engine-workorder', version: '0001-init', sql: 'CREATE TABLE a (id TEXT);' },
4254
+ { moduleId: 'helpdesk', version: '0001-init', sql: 'CREATE TABLE b (id TEXT);' },
4255
+ { moduleId: 'helpdesk', version: '0002-more', sql: 'ALTER TABLE b ADD COLUMN c TEXT;' },
4256
+ ];
4257
+ // Carried: every migration, in the order the host runs them, and a manifest without them.
4258
+ const carried = await publish({ ...base, migrations });
4259
+ expect(await host.admin.versionMigrations(staff, 'migrationsapart', carried)).toEqual(migrations);
4260
+ const manifest = JSON.parse((await host.admin.versionManifest(staff, 'migrationsapart', carried)));
4261
+ expect(manifest).not.toHaveProperty('migrations');
4262
+ expect(manifest).toEqual(base);
4263
+ // `[]` is a version whose modules ship none; an absent field is one that cannot say.
4264
+ expect(await host.admin.versionMigrations(staff, 'migrationsapart', await publish({ ...base, migrations: [] }))).toEqual([]);
4265
+ expect(await host.admin.versionMigrations(staff, 'migrationsapart', await publish(base))).toBeNull();
4266
+ // Over the push's caps, or not shaped like migrations: dropped, and read as not available.
4267
+ // `/deploy` refuses such a set before the upload; this is the cap on whatever else reaches
4268
+ // `publishVersion` (the raw `POST …/versions`, #1765), and the publish still succeeds.
4269
+ const overCap = await publish({
4270
+ ...base,
4271
+ migrations: [{ moduleId: 'helpdesk', version: '0001', sql: 'x'.repeat(512 * 1024 + 1) }],
4272
+ });
4273
+ expect(await host.admin.versionMigrations(staff, 'migrationsapart', overCap)).toBeNull();
4274
+ expect(JSON.parse((await host.admin.versionManifest(staff, 'migrationsapart', overCap)))).not.toHaveProperty('migrations');
4275
+ const malformed = await publish({ ...base, migrations: 'nope' });
4276
+ expect(await host.admin.versionMigrations(staff, 'migrationsapart', malformed)).toBeNull();
4277
+ // A manifest that is not JSON, or none at all: stored as it came, with no SQL to show.
4278
+ const junk = await publish('not json');
4279
+ expect(await host.admin.versionManifest(staff, 'migrationsapart', junk)).toBe('not json');
4280
+ expect(await host.admin.versionMigrations(staff, 'migrationsapart', junk)).toBeNull();
4194
4281
  });
4195
4282
  it('an archived scope blocks the delete naming the reap step; a reaped tombstone never blocks', async () => {
4196
4283
  // A deleted app leaves an `archived` row (restorable via unarchive), then a
@@ -5805,6 +5892,13 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
5805
5892
  // An empty status list means "no status is acceptable" — it must match
5806
5893
  // nothing, never degenerate into an unfiltered read of the whole fleet.
5807
5894
  expect(await host.admin.listScopes(staff, { tenantId: t5, status: [] })).toEqual([]);
5895
+ // A list longer than a Durable Object binds parameters (#1776). Nothing bounds its
5896
+ // length, since a status may repeat, and a `?` per entry was refused past 100.
5897
+ const repeated = await host.admin.listScopes(staff, {
5898
+ tenantId: t5,
5899
+ status: [...Array(150).fill('active'), 'suspended'],
5900
+ });
5901
+ expect(repeated.map((s) => s.id)).toEqual(both.map((s) => s.id));
5808
5902
  await host.admin.unsuspendScope(staff, t5, target.id);
5809
5903
  });
5810
5904
  it('lists the whole fleet across tenants when unfiltered (§4.5)', async () => {
@@ -5865,6 +5959,13 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
5865
5959
  expect(provisions.every((r) => r.action === 'provisionScope')).toBe(true);
5866
5960
  // Empty action list matches nothing rather than everything.
5867
5961
  expect(await host.admin.auditLog(staff, { tenantId: t5, action: [] })).toEqual([]);
5962
+ // A list longer than a Durable Object binds parameters (#1776): an action may repeat,
5963
+ // so nothing bounds its length, and a `?` per entry was refused past 100.
5964
+ const repeated = await host.admin.auditLog(staff, {
5965
+ tenantId: t5,
5966
+ action: [...Array(150).fill('suspendScope'), 'unsuspendScope'],
5967
+ });
5968
+ expect(repeated.map((r) => r.id)).toEqual(lifecycle.map((r) => r.id));
5868
5969
  });
5869
5970
  it('orders oldest-first by default and newest-first on request (§4.5)', async () => {
5870
5971
  const asc = await host.admin.auditLog(staff, { tenantId: t5 });