From 9f9eea6391001f199faf57d1d6df8b2896373207 Mon Sep 17 00:00:00 2001 From: jonasnobile Date: Tue, 5 May 2026 10:33:05 +0200 Subject: [PATCH 1/6] refactor: invert Worker config filter to blacklist oblaka-managed fields Switch from a Pick whitelist to an Omit blacklist so that new wrangler config options (and binding types without an oblaka resource yet) fall through automatically as a fallback. The blacklist excludes only fields managed by oblaka resources, identity fields handled at the top level, and hidden internal fields. Co-Authored-By: Claude Opus 4.6 --- src/resources/worker.ts | 50 ++++++++++++++++++++--------------------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/src/resources/worker.ts b/src/resources/worker.ts index a934ef8..dd4351a 100644 --- a/src/resources/worker.ts +++ b/src/resources/worker.ts @@ -15,33 +15,31 @@ export class Worker implements BindableResource { deleteDurableObjectsOnRemoval?: boolean } & Partial< - Pick< + Omit< Config, - | 'main' - | 'compatibility_date' - | 'vars' - | 'find_additional_modules' - | 'base_dir' - | 'preview_urls' - | 'workers_dev' - | 'routes' - | 'route' - | 'observability' - | 'logpush' - | 'rules' - | 'limits' - | 'no_bundle' - | 'keep_names' - | 'first_party_worker' - | 'minify' - | 'assets' - | 'compliance_region' - | 'build' - | 'define' - | 'jsx_factory' - | 'jsx_fragment' - | 'triggers' - | 'upload_source_maps' + | 'name' + | 'compatibility_flags' + | 'account_id' + | 'kv_namespaces' + | 'r2_buckets' + | 'd1_databases' + | 'queues' + | 'durable_objects' + | 'migrations' + | 'workflows' + | 'containers' + | 'send_email' + | 'services' + | 'vectorize' + | 'analytics_engine_datasets' + | 'browser' + | 'images' + | 'version_metadata' + | 'worker_loaders' + | 'vpc_networks' + | 'ai_search_namespaces' + | 'flagship' + | 'cache' > >, ) {} From ce499b9be918c1ed651660872fca510bb4b99570 Mon Sep 17 00:00:00 2001 From: jonasnobile Date: Wed, 17 Jun 2026 22:33:49 +0200 Subject: [PATCH 2/6] test: assert Worker rejects oblaka-managed binding fields at the type level Add @ts-expect-error coverage proving kv_namespaces is rejected by the Omit blacklist (and that non-managed raw config fields still pass through), locking in the contract introduced by the whitelist-to-blacklist refactor. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/helpers/mock-client.ts | 26 ++++++++++++++++++ tests/resources/worker.test.ts | 49 ++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 tests/helpers/mock-client.ts diff --git a/tests/helpers/mock-client.ts b/tests/helpers/mock-client.ts new file mode 100644 index 0000000..aeb60e9 --- /dev/null +++ b/tests/helpers/mock-client.ts @@ -0,0 +1,26 @@ +import type { Context } from '../../src/types' + +/** + * Builds a Context whose client.fetch is backed by a lookup table keyed by + * `" "`. Exact matches win; otherwise the first key that is a + * prefix of the request matches. Every call is recorded in `calls` so tests can + * assert that, e.g., adopt-on-conflict skipped the create POST. + */ +export function mockContext(handlers: Record, env = 'production') { + const calls: { method: string; url: string }[] = [] + const client = { + async fetch({ method, url }: { method: string; url: string }) { + calls.push({ method, url }) + const signature = `${method} ${url}` + if (signature in handlers) { + return handlers[signature] + } + const prefix = Object.keys(handlers).find(key => signature.startsWith(key)) + if (prefix !== undefined) { + return handlers[prefix] + } + throw new Error(`unexpected request: ${signature}`) + }, + } + return { context: { env, client: client as unknown as Context['client'] } satisfies Context, calls } +} diff --git a/tests/resources/worker.test.ts b/tests/resources/worker.test.ts index ed62407..f7649b2 100644 --- a/tests/resources/worker.test.ts +++ b/tests/resources/worker.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test' import { Worker } from '../../src/resources/worker' import type { Config } from '../../src/types' +import { mockContext } from '../helpers/mock-client' const emptyConfig = {} as Config @@ -14,6 +15,39 @@ const createWorker = (overrides?: Partial[0 }) describe('Worker', () => { + describe('apply', () => { + test('returns existing state without any remote call', async () => { + const worker = createWorker() + const { context, calls } = mockContext({}) + const result = await worker.apply({ state: { id: 'w-1', name: 'production-my-worker' }, context, dryRun: false }) + expect(result).toEqual({ id: 'w-1', name: 'production-my-worker' }) + expect(calls).toHaveLength(0) + }) + + test('adopts an existing remote worker by name instead of failing on create', async () => { + const worker = createWorker() + const { context, calls } = mockContext({ + 'GET /workers/workers': [{ id: 'w-existing', name: 'production-my-worker' }], + }) + const result = await worker.apply({ context, dryRun: false }) + expect(result).toEqual({ id: 'w-existing', name: 'production-my-worker' }) + // No create POST — a blind create would return 10040 "already exists". + expect(calls.some(c => c.method === 'POST')).toBe(false) + }) + + test('creates when no remote worker matches', async () => { + const worker = createWorker() + const { context, calls } = mockContext({ + 'GET /workers/workers': [], + 'POST /workers/workers': { id: 'w-new' }, + 'POST /workers/workers/w-new/versions': {}, + }) + const result = await worker.apply({ context, dryRun: false }) + expect(result).toEqual({ id: 'w-new', name: 'production-my-worker' }) + expect(calls.some(c => c.method === 'POST' && c.url === '/workers/workers')).toBe(true) + }) + }) + describe('getId', () => { test('returns worker resource kind and name', () => { const worker = createWorker() @@ -35,6 +69,21 @@ describe('Worker', () => { }) }) + describe('constructor option types', () => { + test('accepts raw wrangler config fields that oblaka does not manage', () => { + const worker = createWorker({ compatibility_date: '2024-01-01', vars: { FOO: 'bar' } }) + expect(worker.getId()).toEqual({ resource: 'worker', id: 'my-worker' }) + }) + + test('rejects oblaka-managed binding fields', () => { + const worker = createWorker({ + // @ts-expect-error kv_namespaces is managed by the KV resource and must not be set directly + kv_namespaces: [{ binding: 'KV', id: 'abc' }], + }) + expect(worker.getId()).toEqual({ resource: 'worker', id: 'my-worker' }) + }) + }) + describe('configureBinding', () => { test('adds service binding for local env', () => { const worker = createWorker() From a1b83c0245aed200a9a7ea90a500973fb3e34584 Mon Sep 17 00:00:00 2001 From: jonasnobile Date: Wed, 17 Jun 2026 22:36:12 +0200 Subject: [PATCH 3/6] test: add apply coverage for D1, R2, and Queues resources Cover remote-state adoption and create paths using the shared mock-client helper, mirroring the Worker resource tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/resources/d1.test.ts | 33 +++++++++++++++++++++++++++++++++ tests/resources/queues.test.ts | 32 ++++++++++++++++++++++++++++++++ tests/resources/r2.test.ts | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+) diff --git a/tests/resources/d1.test.ts b/tests/resources/d1.test.ts index 55ae376..50fa207 100644 --- a/tests/resources/d1.test.ts +++ b/tests/resources/d1.test.ts @@ -1,10 +1,43 @@ import { describe, expect, test } from 'bun:test' import { D1Database } from '../../src/resources/d1' import type { Config } from '../../src/types' +import { mockContext } from '../helpers/mock-client' const emptyConfig = {} as Config describe('D1Database', () => { + describe('apply', () => { + test('returns existing state without any remote call', async () => { + const db = new D1Database({ name: 'my-db' }) + const { context, calls } = mockContext({}) + const result = await db.apply({ state: { id: 'uuid-1', name: 'production-my-db' }, context, dryRun: false }) + expect(result).toEqual({ id: 'uuid-1', name: 'production-my-db' }) + expect(calls).toHaveLength(0) + }) + + test('adopts an existing remote database by name instead of creating a duplicate', async () => { + const db = new D1Database({ name: 'my-db' }) + const { context, calls } = mockContext({ + 'GET /d1/database?name=production-my-db': [{ uuid: 'uuid-existing', name: 'production-my-db' }], + }) + const result = await db.apply({ context, dryRun: false }) + expect(result).toEqual({ id: 'uuid-existing', name: 'production-my-db' }) + // Crucially, no POST — a blind create would spawn a second empty DB. + expect(calls.some(c => c.method === 'POST')).toBe(false) + }) + + test('creates when no remote database matches', async () => { + const db = new D1Database({ name: 'my-db' }) + const { context, calls } = mockContext({ + 'GET /d1/database?name=production-my-db': [], + 'POST /d1/database': { uuid: 'uuid-new' }, + }) + const result = await db.apply({ context, dryRun: false }) + expect(result).toEqual({ id: 'uuid-new', name: 'production-my-db' }) + expect(calls.some(c => c.method === 'POST' && c.url === '/d1/database')).toBe(true) + }) + }) + describe('getId', () => { test('returns d1_database resource kind', () => { const db = new D1Database({ name: 'my-db' }) diff --git a/tests/resources/queues.test.ts b/tests/resources/queues.test.ts index f6f3cb5..6268a5e 100644 --- a/tests/resources/queues.test.ts +++ b/tests/resources/queues.test.ts @@ -1,10 +1,42 @@ import { describe, expect, test } from 'bun:test' import { Queue } from '../../src/resources/queues' import type { Config } from '../../src/types' +import { mockContext } from '../helpers/mock-client' const emptyConfig = {} as Config describe('Queue', () => { + describe('apply', () => { + test('returns existing state without any remote call', async () => { + const queue = new Queue({ name: 'my-queue' }) + const { context, calls } = mockContext({}) + const result = await queue.apply({ state: { id: 'q-1', name: 'production-my-queue' }, context, dryRun: false }) + expect(result).toEqual({ id: 'q-1', name: 'production-my-queue' }) + expect(calls).toHaveLength(0) + }) + + test('adopts an existing remote queue by name instead of failing on create', async () => { + const queue = new Queue({ name: 'my-queue' }) + const { context, calls } = mockContext({ + 'GET /queues': [{ queue_id: 'q-existing', queue_name: 'production-my-queue' }], + }) + const result = await queue.apply({ context, dryRun: false }) + expect(result).toEqual({ id: 'q-existing', name: 'production-my-queue' }) + expect(calls.some(c => c.method === 'POST')).toBe(false) + }) + + test('creates when no remote queue matches', async () => { + const queue = new Queue({ name: 'my-queue' }) + const { context, calls } = mockContext({ + 'GET /queues': [], + 'POST /queues': { queue_id: 'q-new' }, + }) + const result = await queue.apply({ context, dryRun: false }) + expect(result).toEqual({ id: 'q-new', name: 'production-my-queue' }) + expect(calls.some(c => c.method === 'POST' && c.url === '/queues')).toBe(true) + }) + }) + describe('getId', () => { test('returns queue resource kind', () => { const queue = new Queue({ name: 'my-queue' }) diff --git a/tests/resources/r2.test.ts b/tests/resources/r2.test.ts index 856ab1e..4fa5a59 100644 --- a/tests/resources/r2.test.ts +++ b/tests/resources/r2.test.ts @@ -1,10 +1,42 @@ import { describe, expect, test } from 'bun:test' import { R2Bucket } from '../../src/resources/r2' import type { Config } from '../../src/types' +import { mockContext } from '../helpers/mock-client' const emptyConfig = {} as Config describe('R2Bucket', () => { + describe('apply', () => { + test('returns existing state without any remote call', async () => { + const r2 = new R2Bucket({ name: 'my-bucket' }) + const { context, calls } = mockContext({}) + const result = await r2.apply({ state: { name: 'production-my-bucket' }, context, dryRun: false }) + expect(result).toEqual({ name: 'production-my-bucket' }) + expect(calls).toHaveLength(0) + }) + + test('adopts an existing remote bucket by name instead of failing on create', async () => { + const r2 = new R2Bucket({ name: 'my-bucket' }) + const { context, calls } = mockContext({ + 'GET /r2/buckets': { buckets: [{ name: 'production-my-bucket' }] }, + }) + const result = await r2.apply({ context, dryRun: false }) + expect(result).toEqual({ name: 'production-my-bucket' }) + expect(calls.some(c => c.method === 'POST')).toBe(false) + }) + + test('creates when no remote bucket matches', async () => { + const r2 = new R2Bucket({ name: 'my-bucket' }) + const { context, calls } = mockContext({ + 'GET /r2/buckets': { buckets: [] }, + 'POST /r2/buckets': {}, + }) + const result = await r2.apply({ context, dryRun: false }) + expect(result).toEqual({ name: 'production-my-bucket' }) + expect(calls.some(c => c.method === 'POST' && c.url === '/r2/buckets')).toBe(true) + }) + }) + describe('getId', () => { test('returns r2_bucket resource kind', () => { const r2 = new R2Bucket({ name: 'my-bucket' }) From 7b27d4e7cb93df31ad310ced18aa0adf21bca2e9 Mon Sep 17 00:00:00 2001 From: jonasnobile Date: Wed, 17 Jun 2026 22:40:05 +0200 Subject: [PATCH 4/6] feat: adopt existing remote resources instead of failing on create On lost state, a blind create either fails with a conflict (Worker, R2, Queues) or silently spawns a duplicate (D1 allows same-name databases, risking data loss). Look each resource up by name first and adopt it. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/resources/d1.ts | 13 +++++++++++++ src/resources/queues.ts | 12 ++++++++++++ src/resources/r2.ts | 13 +++++++++++++ src/resources/worker.ts | 13 +++++++++++++ 4 files changed, 51 insertions(+) diff --git a/src/resources/d1.ts b/src/resources/d1.ts index 518f3af..abfa2b3 100644 --- a/src/resources/d1.ts +++ b/src/resources/d1.ts @@ -48,6 +48,19 @@ export class D1Database implements BindableResource { } } + // Adopt an existing database instead of creating a duplicate. D1 allows + // multiple databases with the same name, so on lost state a blind create + // would silently spawn a second, empty DB and the binding would point at + // it — data loss. Look it up by name first and adopt it. + const existingDatabases = await args.context.client.fetch<{ uuid: string; name: string }[]>({ + url: `/d1/database?name=${encodeURIComponent(remoteName)}`, + method: 'GET', + }) + const existingDatabase = existingDatabases.find(d => d.name === remoteName) + if (existingDatabase) { + return { name: remoteName, id: existingDatabase.uuid } + } + const result = await args.context.client.fetch<{ uuid: string }>({ url: `/d1/database`, method: 'POST', diff --git a/src/resources/queues.ts b/src/resources/queues.ts index dab40fa..2ca4add 100644 --- a/src/resources/queues.ts +++ b/src/resources/queues.ts @@ -53,6 +53,18 @@ export class Queue implements BindableResource { } } + // Adopt an existing queue instead of failing on create. On lost state a + // blind create conflicts with the already-existing queue; look it up by + // name and adopt it. + const existingQueues = await args.context.client.fetch<{ queue_id: string; queue_name: string }[]>({ + url: `/queues`, + method: 'GET', + }) + const existingQueue = existingQueues.find(q => q.queue_name === remoteName) + if (existingQueue) { + return { id: existingQueue.queue_id, name: remoteName } + } + const result = await args.context.client.fetch<{ queue_id: string }>({ url: `/queues`, method: 'POST', diff --git a/src/resources/r2.ts b/src/resources/r2.ts index f5c4abc..e1b23f5 100644 --- a/src/resources/r2.ts +++ b/src/resources/r2.ts @@ -44,6 +44,19 @@ export class R2Bucket implements BindableResource { } } + // Adopt an existing bucket instead of failing on create. On lost state a + // blind create returns a conflict for the already-existing bucket; look + // it up by name and adopt it (bucket contents are untouched either way). + const existingBuckets = await args.context.client.fetch<{ buckets: { name: string }[] }>({ + url: `/r2/buckets`, + method: 'GET', + }) + if (existingBuckets.buckets?.some(b => b.name === remoteName)) { + return { + name: remoteName, + } + } + await args.context.client.fetch({ url: `/r2/buckets`, method: 'POST', diff --git a/src/resources/worker.ts b/src/resources/worker.ts index dd4351a..0d0b54b 100644 --- a/src/resources/worker.ts +++ b/src/resources/worker.ts @@ -84,6 +84,19 @@ export class Worker implements BindableResource { } } + // Adopt an existing worker instead of failing on create. State can be + // lost (e.g. the shared cf-state KV namespace gets clobbered by another + // project deploying the same env), in which case a blind create returns + // 10040 "already exists". Look it up by name and adopt it. + const existingWorkers = await args.context.client.fetch<{ id: string; name: string }[]>({ + method: 'GET', + url: `/workers/workers`, + }) + const existingWorker = existingWorkers.find(w => w.name === remoteName) + if (existingWorker) { + return { id: existingWorker.id, name: remoteName } + } + const result = await args.context.client.fetch<{ id: string }>({ method: 'POST', url: `/workers/workers`, From db2fc268b8f498438a0cc5e281a34069d73e5c95 Mon Sep 17 00:00:00 2001 From: jonasnobile Date: Wed, 17 Jun 2026 22:40:09 +0200 Subject: [PATCH 5/6] bump version to 0.0.15 Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index bfd155b..5d1cbc8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oblaka-iac", - "version": "0.0.14", + "version": "0.0.15", "main": "./src/index.ts", "type": "module", "license": "MIT", From f4f860b398b6c819bc2715c725655cd4953a0a8d Mon Sep 17 00:00:00 2001 From: jonasnobile Date: Wed, 17 Jun 2026 22:45:21 +0200 Subject: [PATCH 6/6] fix: request max page size when listing resources for adoption The Worker, Queues, and R2 list endpoints were fetched with the default page size, so an existing resource past page one would be missed and the adopt-on-create path would fall through to a conflicting blind create. Request per_page=1000 (matching the KV namespace lookup in state.ts) so adoption sees the full set. D1 already filters server-side by name. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/resources/queues.ts | 2 +- src/resources/r2.ts | 2 +- src/resources/worker.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/resources/queues.ts b/src/resources/queues.ts index 2ca4add..a685db4 100644 --- a/src/resources/queues.ts +++ b/src/resources/queues.ts @@ -57,7 +57,7 @@ export class Queue implements BindableResource { // blind create conflicts with the already-existing queue; look it up by // name and adopt it. const existingQueues = await args.context.client.fetch<{ queue_id: string; queue_name: string }[]>({ - url: `/queues`, + url: `/queues?per_page=1000`, method: 'GET', }) const existingQueue = existingQueues.find(q => q.queue_name === remoteName) diff --git a/src/resources/r2.ts b/src/resources/r2.ts index e1b23f5..a407576 100644 --- a/src/resources/r2.ts +++ b/src/resources/r2.ts @@ -48,7 +48,7 @@ export class R2Bucket implements BindableResource { // blind create returns a conflict for the already-existing bucket; look // it up by name and adopt it (bucket contents are untouched either way). const existingBuckets = await args.context.client.fetch<{ buckets: { name: string }[] }>({ - url: `/r2/buckets`, + url: `/r2/buckets?per_page=1000`, method: 'GET', }) if (existingBuckets.buckets?.some(b => b.name === remoteName)) { diff --git a/src/resources/worker.ts b/src/resources/worker.ts index 0d0b54b..c234939 100644 --- a/src/resources/worker.ts +++ b/src/resources/worker.ts @@ -90,7 +90,7 @@ export class Worker implements BindableResource { // 10040 "already exists". Look it up by name and adopt it. const existingWorkers = await args.context.client.fetch<{ id: string; name: string }[]>({ method: 'GET', - url: `/workers/workers`, + url: `/workers/workers?per_page=1000`, }) const existingWorker = existingWorkers.find(w => w.name === remoteName) if (existingWorker) {