@tstdl/base 0.93.162 → 0.93.164

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.
@@ -8,17 +8,15 @@ var __metadata = (this && this.__metadata) || function (k, v) {
8
8
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
9
9
  };
10
10
  import { sql } from 'drizzle-orm';
11
- import { beforeAll, describe, expect, test } from 'vitest';
12
- import { Injector, runInInjectionContext } from '../../injector/index.js';
11
+ import { describe, expect } from 'vitest';
13
12
  import { StringProperty } from '../../schema/index.js';
13
+ import { getIntegrationTest } from '../../testing/index.js';
14
14
  import { Table } from '../decorators.js';
15
15
  import { Entity } from '../entity.js';
16
- import { configureOrm, Database } from '../server/index.js';
17
16
  import { injectRepository } from '../server/repository.js';
17
+ const schema = 'test_orm_tx_nested';
18
+ const test = getIntegrationTest({ orm: { schema } });
18
19
  describe('ORM Repository Transactions Nested (Integration)', () => {
19
- let injector;
20
- let db;
21
- const schema = 'test_orm_tx_nested';
22
20
  let TxEntity = class TxEntity extends Entity {
23
21
  name;
24
22
  };
@@ -29,15 +27,7 @@ describe('ORM Repository Transactions Nested (Integration)', () => {
29
27
  TxEntity = __decorate([
30
28
  Table('tx_entities', { schema })
31
29
  ], TxEntity);
32
- beforeAll(async () => {
33
- injector = new Injector('Test');
34
- configureOrm({
35
- repositoryConfig: { schema },
36
- connection: {
37
- host: '127.0.0.1', port: 5432, user: 'tstdl', password: 'wf7rq6glrk5jykne', database: 'tstdl',
38
- },
39
- });
40
- db = injector.resolve(Database);
30
+ test.beforeEach(async ({ database: db }) => {
41
31
  await db.execute(sql `CREATE SCHEMA IF NOT EXISTS ${sql.identifier(schema)}`);
42
32
  await db.execute(sql `DROP TABLE IF EXISTS ${sql.identifier(schema)}.${sql.identifier('tx_entities')} CASCADE`);
43
33
  await db.execute(sql `
@@ -52,113 +42,121 @@ describe('ORM Repository Transactions Nested (Integration)', () => {
52
42
  )
53
43
  `);
54
44
  });
55
- test('should commit nested transaction', async () => {
45
+ test('should commit nested transaction', async ({ database: db }) => {
46
+ const repo = injectRepository(TxEntity);
47
+ await db.execute(sql `TRUNCATE TABLE ${sql.identifier(schema)}.${sql.identifier('tx_entities')} CASCADE`);
48
+ await repo.transaction(async (tx1) => {
49
+ const repo1 = repo.withTransaction(tx1);
50
+ await repo1.insert(Object.assign(new TxEntity(), { name: 'Outer' }));
51
+ await repo1.transaction(async (tx2) => {
52
+ const repo2 = repo1.withTransaction(tx2);
53
+ await repo2.insert(Object.assign(new TxEntity(), { name: 'Inner' }));
54
+ });
55
+ });
56
+ const count = await repo.count();
57
+ expect(count).toBe(2);
58
+ });
59
+ test('should rollback nested transaction (inner fails)', async ({ database: db }) => {
60
+ const repo = injectRepository(TxEntity);
56
61
  await db.execute(sql `TRUNCATE TABLE ${sql.identifier(schema)}.${sql.identifier('tx_entities')} CASCADE`);
57
- await runInInjectionContext(injector, async () => {
58
- const repo = injectRepository(TxEntity);
62
+ try {
59
63
  await repo.transaction(async (tx1) => {
60
64
  const repo1 = repo.withTransaction(tx1);
61
65
  await repo1.insert(Object.assign(new TxEntity(), { name: 'Outer' }));
62
66
  await repo1.transaction(async (tx2) => {
63
67
  const repo2 = repo1.withTransaction(tx2);
64
68
  await repo2.insert(Object.assign(new TxEntity(), { name: 'Inner' }));
69
+ throw new Error('Fail Inner');
65
70
  });
66
71
  });
67
- const count = await repo.count();
68
- expect(count).toBe(2);
69
- });
72
+ }
73
+ catch (e) {
74
+ expect(e.message).toBe('Fail Inner');
75
+ }
76
+ const count = await repo.count();
77
+ expect(count).toBe(0); // Outer should rollback too if error propagates
70
78
  });
71
- test('should rollback nested transaction (inner fails)', async () => {
79
+ test('should rollback nested transaction but keep outer if caught', async ({ database: db }) => {
80
+ const repo = injectRepository(TxEntity);
72
81
  await db.execute(sql `TRUNCATE TABLE ${sql.identifier(schema)}.${sql.identifier('tx_entities')} CASCADE`);
73
- await runInInjectionContext(injector, async () => {
74
- const repo = injectRepository(TxEntity);
82
+ await repo.transaction(async (tx1) => {
83
+ const repo1 = repo.withTransaction(tx1);
84
+ await repo1.insert(Object.assign(new TxEntity(), { name: 'Outer' }));
75
85
  try {
76
- await repo.transaction(async (tx1) => {
77
- const repo1 = repo.withTransaction(tx1);
78
- await repo1.insert(Object.assign(new TxEntity(), { name: 'Outer' }));
79
- await repo1.transaction(async (tx2) => {
80
- const repo2 = repo1.withTransaction(tx2);
81
- await repo2.insert(Object.assign(new TxEntity(), { name: 'Inner' }));
82
- throw new Error('Fail Inner');
83
- });
86
+ await repo1.transaction(async (tx2) => {
87
+ const repo2 = repo1.withTransaction(tx2);
88
+ await repo2.insert(Object.assign(new TxEntity(), { name: 'Inner' }));
89
+ throw new Error('Fail Inner');
84
90
  });
85
91
  }
86
- catch (e) {
87
- expect(e.message).toBe('Fail Inner');
92
+ catch {
93
+ // Swallow error to keep outer alive
88
94
  }
89
- const count = await repo.count();
90
- expect(count).toBe(0); // Outer should rollback too if error propagates
91
95
  });
96
+ const all = await repo.loadAll();
97
+ expect(all).toHaveLength(1);
98
+ expect(all[0].name).toBe('Outer');
92
99
  });
93
- test('should rollback nested transaction but keep outer if caught', async () => {
100
+ test('should rollback outer transaction (outer fails after inner commit)', async ({ database: db }) => {
101
+ const repo = injectRepository(TxEntity);
94
102
  await db.execute(sql `TRUNCATE TABLE ${sql.identifier(schema)}.${sql.identifier('tx_entities')} CASCADE`);
95
- await runInInjectionContext(injector, async () => {
96
- const repo = injectRepository(TxEntity);
103
+ try {
97
104
  await repo.transaction(async (tx1) => {
98
105
  const repo1 = repo.withTransaction(tx1);
99
106
  await repo1.insert(Object.assign(new TxEntity(), { name: 'Outer' }));
100
- try {
101
- await repo1.transaction(async (tx2) => {
102
- const repo2 = repo1.withTransaction(tx2);
103
- await repo2.insert(Object.assign(new TxEntity(), { name: 'Inner' }));
104
- throw new Error('Fail Inner');
105
- });
106
- }
107
- catch {
108
- // Swallow error to keep outer alive
109
- }
107
+ await repo1.transaction(async (tx2) => {
108
+ const repo2 = repo1.withTransaction(tx2);
109
+ await repo2.insert(Object.assign(new TxEntity(), { name: 'Inner' }));
110
+ });
111
+ throw new Error('Fail Outer');
110
112
  });
111
- const all = await repo.loadAll();
112
- expect(all).toHaveLength(1);
113
- expect(all[0].name).toBe('Outer');
114
- });
113
+ }
114
+ catch {
115
+ // Expected
116
+ }
117
+ const count = await repo.count();
118
+ expect(count).toBe(0);
115
119
  });
116
- test('should rollback outer transaction (outer fails after inner commit)', async () => {
120
+ test('should support multiple nested transactions sequentially', async ({ database: db }) => {
121
+ const repo = injectRepository(TxEntity);
117
122
  await db.execute(sql `TRUNCATE TABLE ${sql.identifier(schema)}.${sql.identifier('tx_entities')} CASCADE`);
118
- await runInInjectionContext(injector, async () => {
119
- const repo = injectRepository(TxEntity);
120
- try {
121
- await repo.transaction(async (tx1) => {
122
- const repo1 = repo.withTransaction(tx1);
123
- await repo1.insert(Object.assign(new TxEntity(), { name: 'Outer' }));
124
- await repo1.transaction(async (tx2) => {
125
- const repo2 = repo1.withTransaction(tx2);
126
- await repo2.insert(Object.assign(new TxEntity(), { name: 'Inner' }));
127
- });
128
- throw new Error('Fail Outer');
129
- });
130
- }
131
- catch {
132
- // Expected
133
- }
134
- const count = await repo.count();
135
- expect(count).toBe(0);
123
+ await repo.transaction(async (tx1) => {
124
+ const repo1 = repo.withTransaction(tx1);
125
+ await repo1.transaction(async (tx2) => {
126
+ await repo1.withTransaction(tx2).insert(Object.assign(new TxEntity(), { name: 'Inner1' }));
127
+ });
128
+ await repo1.transaction(async (tx3) => {
129
+ await repo1.withTransaction(tx3).insert(Object.assign(new TxEntity(), { name: 'Inner2' }));
130
+ });
136
131
  });
132
+ const count = await repo.count();
133
+ expect(count).toBe(2);
137
134
  });
138
- test('should support multiple nested transactions sequentially', async () => {
135
+ test('should support deep nesting (3 levels)', async ({ database: db }) => {
136
+ const repo = injectRepository(TxEntity);
139
137
  await db.execute(sql `TRUNCATE TABLE ${sql.identifier(schema)}.${sql.identifier('tx_entities')} CASCADE`);
140
- await runInInjectionContext(injector, async () => {
141
- const repo = injectRepository(TxEntity);
142
- await repo.transaction(async (tx1) => {
143
- const repo1 = repo.withTransaction(tx1);
144
- await repo1.transaction(async (tx2) => {
145
- await repo1.withTransaction(tx2).insert(Object.assign(new TxEntity(), { name: 'Inner1' }));
146
- });
147
- await repo1.transaction(async (tx3) => {
148
- await repo1.withTransaction(tx3).insert(Object.assign(new TxEntity(), { name: 'Inner2' }));
138
+ await repo.transaction(async (tx1) => {
139
+ const r1 = repo.withTransaction(tx1);
140
+ await r1.insert(Object.assign(new TxEntity(), { name: 'L1' }));
141
+ await r1.transaction(async (tx2) => {
142
+ const r2 = r1.withTransaction(tx2);
143
+ await r2.insert(Object.assign(new TxEntity(), { name: 'L2' }));
144
+ await r2.transaction(async (tx3) => {
145
+ const r3 = r2.withTransaction(tx3);
146
+ await r3.insert(Object.assign(new TxEntity(), { name: 'L3' }));
149
147
  });
150
148
  });
151
- const count = await repo.count();
152
- expect(count).toBe(2);
153
149
  });
150
+ const count = await repo.count();
151
+ expect(count).toBe(3);
154
152
  });
155
- test('should support deep nesting (3 levels)', async () => {
153
+ test('should rollback deep nesting middle layer', async ({ database: db }) => {
154
+ const repo = injectRepository(TxEntity);
156
155
  await db.execute(sql `TRUNCATE TABLE ${sql.identifier(schema)}.${sql.identifier('tx_entities')} CASCADE`);
157
- await runInInjectionContext(injector, async () => {
158
- const repo = injectRepository(TxEntity);
159
- await repo.transaction(async (tx1) => {
160
- const r1 = repo.withTransaction(tx1);
161
- await r1.insert(Object.assign(new TxEntity(), { name: 'L1' }));
156
+ await repo.transaction(async (tx1) => {
157
+ const r1 = repo.withTransaction(tx1);
158
+ await r1.insert(Object.assign(new TxEntity(), { name: 'L1' }));
159
+ try {
162
160
  await r1.transaction(async (tx2) => {
163
161
  const r2 = r1.withTransaction(tx2);
164
162
  await r2.insert(Object.assign(new TxEntity(), { name: 'L2' }));
@@ -166,37 +164,15 @@ describe('ORM Repository Transactions Nested (Integration)', () => {
166
164
  const r3 = r2.withTransaction(tx3);
167
165
  await r3.insert(Object.assign(new TxEntity(), { name: 'L3' }));
168
166
  });
167
+ throw new Error('Fail L2');
169
168
  });
170
- });
171
- const count = await repo.count();
172
- expect(count).toBe(3);
173
- });
174
- });
175
- test('should rollback deep nesting middle layer', async () => {
176
- await db.execute(sql `TRUNCATE TABLE ${sql.identifier(schema)}.${sql.identifier('tx_entities')} CASCADE`);
177
- await runInInjectionContext(injector, async () => {
178
- const repo = injectRepository(TxEntity);
179
- await repo.transaction(async (tx1) => {
180
- const r1 = repo.withTransaction(tx1);
181
- await r1.insert(Object.assign(new TxEntity(), { name: 'L1' }));
182
- try {
183
- await r1.transaction(async (tx2) => {
184
- const r2 = r1.withTransaction(tx2);
185
- await r2.insert(Object.assign(new TxEntity(), { name: 'L2' }));
186
- await r2.transaction(async (tx3) => {
187
- const r3 = r2.withTransaction(tx3);
188
- await r3.insert(Object.assign(new TxEntity(), { name: 'L3' }));
189
- });
190
- throw new Error('Fail L2');
191
- });
192
- }
193
- catch {
194
- // Catch L2 failure
195
- }
196
- });
197
- const all = await repo.loadAll();
198
- expect(all).toHaveLength(1);
199
- expect(all[0].name).toBe('L1');
169
+ }
170
+ catch {
171
+ // Catch L2 failure
172
+ }
200
173
  });
174
+ const all = await repo.loadAll();
175
+ expect(all).toHaveLength(1);
176
+ expect(all[0].name).toBe('L1');
201
177
  });
202
178
  });
@@ -10,10 +10,11 @@ var __metadata = (this && this.__metadata) || function (k, v) {
10
10
  import { sql } from 'drizzle-orm';
11
11
  import { Injector, runInInjectionContext, Singleton } from '../../injector/index.js';
12
12
  import { StringProperty } from '../../schema/index.js';
13
+ import { setupIntegrationTest } from '../../testing/index.js';
13
14
  import { beforeAll, describe, expect, test } from 'vitest';
14
15
  import { Table } from '../decorators.js';
15
16
  import { Entity } from '../entity.js';
16
- import { configureOrm, Database } from '../server/index.js';
17
+ import { Database } from '../server/index.js';
17
18
  import { getRepository } from '../server/repository.js';
18
19
  import { Transactional } from '../server/transactional.js';
19
20
  describe('ORM Transactional (Integration)', () => {
@@ -47,20 +48,10 @@ describe('ORM Transactional (Integration)', () => {
47
48
  Singleton()
48
49
  ], TransactionalService);
49
50
  beforeAll(async () => {
50
- injector = new Injector('Test');
51
51
  const encryptionSecret = new Uint8Array(32).fill(1);
52
- configureOrm({
53
- repositoryConfig: { schema },
54
- connection: {
55
- host: '127.0.0.1',
56
- port: 5432,
57
- user: 'tstdl',
58
- password: 'wf7rq6glrk5jykne',
59
- database: 'tstdl',
60
- },
61
- encryptionSecret,
62
- });
63
- db = injector.resolve(Database);
52
+ const context = await setupIntegrationTest({ orm: { schema, encryptionSecret } });
53
+ injector = context.injector;
54
+ db = context.database;
64
55
  await db.execute(sql `CREATE SCHEMA IF NOT EXISTS ${sql.identifier(schema)}`);
65
56
  await db.execute(sql `DROP TABLE IF EXISTS ${sql.identifier(schema)}.${sql.identifier('transactional_items')} CASCADE`);
66
57
  await db.execute(sql `
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tstdl/base",
3
- "version": "0.93.162",
3
+ "version": "0.93.164",
4
4
  "author": "Patrick Hein",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -152,9 +152,9 @@
152
152
  "type-fest": "^5.4"
153
153
  },
154
154
  "peerDependencies": {
155
- "@aws-sdk/client-s3": "^3.1003",
156
- "@aws-sdk/s3-request-presigner": "^3.1003",
157
- "@genkit-ai/google-genai": "^1.29",
155
+ "@aws-sdk/client-s3": "^3.1010",
156
+ "@aws-sdk/s3-request-presigner": "^3.1010",
157
+ "@genkit-ai/google-genai": "^1.30",
158
158
  "@google-cloud/storage": "^7.19",
159
159
  "@toon-format/toon": "^2.1.0",
160
160
  "@tstdl/angular": "^0.93",
@@ -164,16 +164,16 @@
164
164
  "@zxcvbn-ts/language-en": "^3.0",
165
165
  "drizzle-orm": "^0.45",
166
166
  "file-type": "^21.3",
167
- "genkit": "^1.29",
167
+ "genkit": "^1.30",
168
168
  "handlebars": "^4.7",
169
169
  "mjml": "^4.18",
170
170
  "nodemailer": "^8.0",
171
171
  "pg": "^8.20",
172
172
  "playwright": "^1.58",
173
- "preact": "^10.28",
173
+ "preact": "^10.29",
174
174
  "preact-render-to-string": "^6.6",
175
175
  "sharp": "^0.34",
176
- "undici": "^7.22",
176
+ "undici": "^7.24",
177
177
  "urlpattern-polyfill": "^10.1",
178
178
  "zod": "^3.25"
179
179
  },
@@ -191,8 +191,8 @@
191
191
  "@types/node": "25",
192
192
  "@types/nodemailer": "7.0",
193
193
  "@types/pg": "8.18",
194
- "@vitest/coverage-v8": "4.0",
195
- "@vitest/ui": "4.0",
194
+ "@vitest/coverage-v8": "4.1",
195
+ "@vitest/ui": "4.1",
196
196
  "concurrently": "9.2",
197
197
  "drizzle-kit": "0.31",
198
198
  "eslint": "9.39",
@@ -202,9 +202,9 @@
202
202
  "typedoc-plugin-markdown": "4.10",
203
203
  "typedoc-plugin-missing-exports": "4.1",
204
204
  "typescript": "5.9",
205
- "typescript-eslint": "8.56",
205
+ "typescript-eslint": "8.57",
206
206
  "vite-tsconfig-paths": "6.1",
207
- "vitest": "4.0"
207
+ "vitest": "4.1"
208
208
  },
209
209
  "overrides": {
210
210
  "drizzle-kit": {
@@ -1,20 +1,17 @@
1
1
  import { and, eq, sql } from 'drizzle-orm';
2
- import { beforeAll, describe, expect, vi } from 'vitest';
2
+ import { describe, expect, vi } from 'vitest';
3
3
  import { inject } from '../../injector/index.js';
4
4
  import { TRANSACTION_TIMESTAMP } from '../../orm/index.js';
5
5
  import { injectRepository } from '../../orm/server/index.js';
6
- import { setupIntegrationTest, testInInjector } from '../../testing/index.js';
6
+ import { getIntegrationTest } from '../../testing/index.js';
7
7
  import { timeout } from '../../utils/timing.js';
8
8
  import { taskArchive as taskArchiveTable, taskDependency as taskDependencyTable, task as taskTable } from '../postgres/schemas.js';
9
9
  import { PostgresTaskQueue } from '../postgres/task-queue.js';
10
10
  import { PostgresTask, PostgresTaskArchive } from '../postgres/task.model.js';
11
11
  import { TaskDependencyType, TaskStatus } from '../task-queue.js';
12
+ const test = getIntegrationTest({ modules: { taskQueue: true } });
12
13
  describe('Task Queue Optimization Edge Cases', () => {
13
- let context;
14
- beforeAll(async () => {
15
- context = await setupIntegrationTest({ modules: { taskQueue: true } });
16
- });
17
- testInInjector('should notify unique namespaces exactly once in incrementCounters', () => context.injector, async () => {
14
+ test('should notify unique namespaces exactly once in incrementCounters', async () => {
18
15
  const q1 = inject(PostgresTaskQueue, 'ns-1');
19
16
  const q2 = inject(PostgresTaskQueue, 'ns-2');
20
17
  const notifySpy = vi.spyOn(PostgresTaskQueue.prototype, 'notify');
@@ -32,7 +29,7 @@ describe('Task Queue Optimization Edge Cases', () => {
32
29
  expect(notifiedNamespaces.filter(n => n == 'ns-1').length).toBe(1);
33
30
  expect(notifiedNamespaces.filter(n => n == 'ns-2').length).toBe(1);
34
31
  });
35
- testInInjector('should resolve edge and abort on unmatched terminal status in resolveDependenciesMany', () => context.injector, async () => {
32
+ test('should resolve edge and abort on unmatched terminal status in resolveDependenciesMany', async () => {
36
33
  const queue = inject(PostgresTaskQueue, 'test-namespace');
37
34
  const repository = injectRepository(PostgresTask);
38
35
  const [parent] = await queue.enqueueMany([{ type: 'parent', data: {}, abortOnDependencyFailure: true }], { returnTasks: true });
@@ -48,7 +45,7 @@ describe('Task Queue Optimization Edge Cases', () => {
48
45
  const updatedParent = await queue.getTask(parent.id);
49
46
  expect(updatedParent.status).toBe(TaskStatus.Skipped);
50
47
  });
51
- testInInjector('should handle hard timeout during touch', () => context.injector, async () => {
48
+ test('should handle hard timeout during touch', async () => {
52
49
  // Configure with small maxExecutionTime
53
50
  const queue = inject(PostgresTaskQueue, { namespace: 'timeout-test', maxExecutionTime: 10 });
54
51
  const [task] = await queue.enqueueMany([{ type: 'test', data: {} }], { returnTasks: true });
@@ -59,18 +56,18 @@ describe('Task Queue Optimization Edge Cases', () => {
59
56
  const updated = await queue.getTask(runningTask.id);
60
57
  expect(updated.status).toBe(TaskStatus.TimedOut);
61
58
  });
62
- testInInjector('should handle non-existent tasks in complete and fail', () => context.injector, async () => {
59
+ test('should handle non-existent tasks in complete and fail', async () => {
63
60
  const queue = inject(PostgresTaskQueue, 'missing-test');
64
61
  const fakeTask = { id: crypto.randomUUID(), token: crypto.randomUUID(), tries: 0 };
65
62
  await expect(queue.complete(fakeTask)).resolves.toBeUndefined();
66
63
  await expect(queue.fail(fakeTask, new Error('fail'))).resolves.toBeUndefined();
67
64
  });
68
- testInInjector('should handle terminal tasks with no dependents in resolveDependenciesMany', () => context.injector, async () => {
65
+ test('should handle terminal tasks with no dependents in resolveDependenciesMany', async () => {
69
66
  const queue = inject(PostgresTaskQueue, 'no-deps-test');
70
67
  const [task] = await queue.enqueueMany([{ type: 'test', data: {} }], { returnTasks: true });
71
68
  await queue.resolveDependenciesMany([{ id: task.id, status: TaskStatus.Completed }]);
72
69
  });
73
- testInInjector('should handle archival and purge in maintenance', () => context.injector, async () => {
70
+ test('should handle archival and purge in maintenance', async () => {
74
71
  const namespace = `archival-test-${crypto.randomUUID()}`;
75
72
  // Configure with small retention
76
73
  const queue = inject(PostgresTaskQueue, { namespace, retention: 1000, archiveRetention: 1000 });
@@ -93,7 +90,7 @@ describe('Task Queue Optimization Edge Cases', () => {
93
90
  const purged = await archiveRepository.load(task.id).catch(() => undefined);
94
91
  expect(purged).toBeUndefined();
95
92
  });
96
- testInInjector('should notify on restart', () => context.injector, async () => {
93
+ test('should notify on restart', async () => {
97
94
  const namespace = `restart-test-${crypto.randomUUID()}`;
98
95
  const queue = inject(PostgresTaskQueue, namespace);
99
96
  const notifySpy = vi.spyOn(PostgresTaskQueue.prototype, 'notify');
@@ -103,7 +100,7 @@ describe('Task Queue Optimization Edge Cases', () => {
103
100
  await queue.restart(task.id);
104
101
  expect(notifySpy).toHaveBeenCalledWith(namespace);
105
102
  });
106
- testInInjector('should use exponential backoff for zombies', () => context.injector, async () => {
103
+ test('should use exponential backoff for zombies', async () => {
107
104
  const namespace = `zombie-backoff-${crypto.randomUUID()}`;
108
105
  // Configure with standard growth
109
106
  const queue = inject(PostgresTaskQueue, { namespace, retryDelayMinimum: 1000, retryDelayGrowth: 2 });
@@ -1,6 +1,7 @@
1
1
  import { eq, or } from 'drizzle-orm';
2
2
  import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
3
3
  import { CancellationToken } from '../../cancellation/index.js';
4
+ import { CircuitBreaker, CircuitBreakerState } from '../../circuit-breaker/index.js';
4
5
  import { Database } from '../../orm/server/index.js';
5
6
  import { TaskQueueProvider, TaskStatus } from '../../task-queue/index.js';
6
7
  import { task as taskTable } from '../../task-queue/postgres/schemas.js';
@@ -356,7 +357,7 @@ describe('PostgresQueue (Distributed Task Orchestration)', () => {
356
357
  const t3Attempt = await queue.dequeue();
357
358
  expect(t3Attempt).toBeUndefined();
358
359
  });
359
- it('should allow a single probe in Half-Open state', async () => {
360
+ it('should only allow a single active task in Half-Open state', async () => {
360
361
  await queue.enqueueMany([
361
362
  { type: 'foo', data: { foo: '1' } },
362
363
  { type: 'foo', data: { foo: '2' } },
@@ -368,7 +369,33 @@ describe('PostgresQueue (Distributed Task Orchestration)', () => {
368
369
  const probe = await queue.dequeue();
369
370
  expect(probe).toBeDefined();
370
371
  const secondAttempt = await queue.dequeue();
371
- expect(secondAttempt).toBeUndefined(); // Only 1 probe allowed in Half-Open
372
+ expect(secondAttempt).toBeUndefined(); // Only 1 task allowed to RUN in Half-Open
373
+ });
374
+ it('should not stall forever when circuit breaker is Half-Open and no tasks are found', async () => {
375
+ // 1. Trip the breaker
376
+ await queue.enqueue('foo', { foo: '1' });
377
+ await queue.enqueue('foo', { foo: '2' });
378
+ await queue.fail((await queue.dequeue()), 'err', { fatal: true });
379
+ await queue.fail((await queue.dequeue()), 'err', { fatal: true });
380
+ const cb = injector.resolve(CircuitBreaker, queue.namespace);
381
+ let status = await cb.check();
382
+ expect(status.state).toBe(CircuitBreakerState.Open);
383
+ expect(status.allowed).toBe(false);
384
+ // 2. Wait for timeout (50ms)
385
+ await timeout(75);
386
+ // 3. First dequeue - should be the probe, but find nothing (we enqueued and failed the only task)
387
+ const tasks1 = await queue.dequeueMany(1);
388
+ expect(tasks1).toHaveLength(0);
389
+ status = await cb.check();
390
+ expect(status.state).toBe(CircuitBreakerState.HalfOpen);
391
+ // 4. Second dequeue - should still be allowed (but find nothing)
392
+ const tasks2 = await queue.dequeueMany(1);
393
+ expect(tasks2).toHaveLength(0);
394
+ // 5. Add a task
395
+ await queue.enqueue('foo', { foo: 'new' });
396
+ // 6. Third dequeue - should find the task now!
397
+ const tasks3 = await queue.dequeueMany(1);
398
+ expect(tasks3).toHaveLength(1);
372
399
  });
373
400
  });
374
401
  describe('Timeouts and Maintenance (Pruning)', () => {
package/testing/README.md CHANGED
@@ -80,51 +80,40 @@ The `setupIntegrationTest` utility (found in `source/testing/integration-setup.t
80
80
  4. **Schema Isolation**: Automatically creates and uses PostgreSQL schemas for isolation.
81
81
  5. **Modules**: Optional configuration for `taskQueue`, `authentication`, `objectStorage`, `notification`, etc.
82
82
 
83
- ### Injection Context: `testInInjector`
83
+ ### Injection Context: `getIntegrationTest`
84
84
 
85
- Integration tests may require an injection context to resolve services and repositories. You can use the `testInInjector` (or `itInInjector`) helper to automatically wrap your test body.
86
-
87
- > [!IMPORTANT]
88
- > `testInInjector` is **only required** for code that uses `inject()` or `injectRepository()` outside of a class constructor/initializer (e.g. ad-hoc repository injection in a test body).
89
- >
90
- > If you resolve your services in `beforeAll` or `beforeEach`, calling their methods usually does **not** require `testInInjector`.
91
-
92
- > [!TIP]
93
- > **Performance & Reusability**: For most integration tests, resolve services once in `beforeAll` and reuse the variables in your tests.
85
+ Integration tests often require an injection context to resolve services and repositories using `inject()`. You can use the `getIntegrationTest` helper to create a specialized `test` function that automatically manages this context and simplifies setup.
94
86
 
95
87
  ```typescript
96
- import { beforeAll, describe, expect, test } from 'vitest';
97
- import { setupIntegrationTest, testInInjector } from '#/testing/index.js';
88
+ import { describe, expect } from 'vitest';
89
+ import { inject } from '#/injector/index.js';
90
+ import { getIntegrationTest } from '#/testing/index.js';
98
91
  import { MyService } from '../my-service.js';
99
92
 
100
- describe('MyService Integration', () => {
101
- let injector: Injector;
102
- let myService: MyService;
103
-
104
- beforeAll(async () => {
105
- ({ injector } = await setupIntegrationTest({
106
- modules: { taskQueue: true },
107
- }));
108
-
109
- // Resolve reused services once
110
- myService = injector.resolve(MyService);
111
- });
93
+ // 1. Create a specialized test function with your required modules
94
+ const test = getIntegrationTest({
95
+ modules: { taskQueue: true },
96
+ });
112
97
 
113
- // Calling methods on resolved services does NOT require testInInjector
98
+ describe('MyService Integration', () => {
99
+ // 2. Use the returned 'test' function - it automatically runs in an injection context
114
100
  test('should process data', async () => {
101
+ // You can use inject() directly in the test body!
102
+ const myService = inject(MyService);
115
103
  const result = await myService.process();
104
+
116
105
  expect(result.success).toBe(true);
117
106
  });
118
-
119
- // testInInjector is ONLY needed if you use inject() directly in the test body
120
- testInInjector('should work with ad-hoc injection', () => injector, async () => {
121
- const repo = injectRepository(SomeEntity);
122
- // ...
123
- },
124
- );
125
107
  });
126
108
  ```
127
109
 
110
+ > [!IMPORTANT]
111
+ > **Async Context Limitation**: Due to how asynchronous execution works in Node.js, `inject()` calls **must** occur before any `await` statements in your test function. If you need to inject something after an `await`, resolve it at the beginning of the test.
112
+
113
+ > [!TIP]
114
+ > **Fixtures**: The `test` function provided by `getIntegrationTest` is an extended Vitest test. You can access `injector`, `database`, and `context` as fixtures if needed:
115
+ > `test('example', async ({ injector, database }) => { ... })`
116
+
128
117
  ## Best Practices & Guidelines
129
118
 
130
119
  ### 1. Isolation & Performance
@@ -154,7 +143,7 @@ describe('MyService Integration', () => {
154
143
  injector.register(MailService, { useValue: mailServiceMock });
155
144
  ```
156
145
  - **Injection Context**: Any code that uses repositories or services relying on `inject()` outside of a class constructor/initializer **must** be wrapped in an injection context.
157
- - **Preferred**: Use `testInInjector` (or `itInInjector`) for the entire test body.
146
+ - **Preferred**: Use the test function returned by `getIntegrationTest`.
158
147
  - **Alternative**: Use `runInInjectionContext(injector, async () => { ... })` for specific blocks within a test or hook.
159
148
  - **Mock Reset**: Always call `vi.clearAllMocks()` in `beforeEach` if using spys or mocks to ensure test independence.
160
149
 
@@ -183,8 +172,7 @@ describe('MyService Integration', () => {
183
172
  ### 5. Common Helpers (from `source/testing/`)
184
173
 
185
174
  - `setupIntegrationTest(options)`: Main entry point for integration tests.
186
- - `testInInjector(name, injector, fn, options)`: Runs a test within an injection context.
187
- - `itInInjector`: Alias for `testInInjector`.
175
+ - `getIntegrationTest(options)`: Returns a Vitest test function pre-configured with the given options and an injection context.
188
176
  - `truncateTables(database, schema, tables)`: Clears data from specified tables.
189
177
  - `dropTables(database, schema, tables)`: Drops specified tables.
190
178
  - `clearTenantData(database, schema, tables, tenantId)`: Deletes data for a specific tenant.