diff --git a/packages/types/__tests__/domains.test.ts b/packages/types/__tests__/domains.test.ts index 6594f302..290ad530 100644 --- a/packages/types/__tests__/domains.test.ts +++ b/packages/types/__tests__/domains.test.ts @@ -8,6 +8,10 @@ import { getConnections, PgTestClient } from 'pgsql-test'; // - email: must contain @ (value ~ '@') // - image: jsonb object requiring 'url' OR 'id' OR 'key', with type validation, optional bucket/provider/mime/versions (versions is array) // - upload: jsonb object requiring 'url' OR 'id' OR 'key', with type validation on all fields, optional bucket/provider/mime +// both also carry the files-row projection keys: bucket_id (uuid string), size (number), filename (string) + +const fileId = '0d1e3d64-1e2a-4c7f-9c3a-6f7f9f2b1c44'; +const bucketId = '9a7f1c2e-4b6d-4a11-8f30-2c5d7e9a0b13'; const validUrls = [ 'http://foo.com/blah_blah', @@ -67,7 +71,9 @@ const validImages = [ { key: 'some-image-key' }, { id: 'private-image', bucket: 'my-bucket', provider: 's3' }, { url: 'https://example.com/image.png', versions: ['thumb', 'medium', 'large'] }, - { id: 'image-with-versions', versions: [{ size: 'thumb' }, { size: 'large' }] } + { id: 'image-with-versions', versions: [{ size: 'thumb' }, { size: 'large' }] }, + { id: fileId, key: 'abc', mime: 'image/png', bucket_id: bucketId, size: 12345, filename: 'hero.png' }, + { id: fileId, bucket_id: bucketId.toUpperCase() } ]; const invalidImages = [ @@ -81,6 +87,10 @@ const invalidImages = [ { url: 'https://example.com/image.png', provider: true }, { url: 'https://example.com/image.png', mime: ['array'] }, { url: 'https://example.com/image.png', versions: 'not-an-array' }, + { id: fileId, bucket_id: 'not-a-uuid' }, + { id: fileId, bucket_id: bucketId.replace(/-/g, '') }, + { id: fileId, size: '12345' }, + { id: fileId, filename: 42 }, 'not-an-object', ['array-not-object'] ]; @@ -93,7 +103,9 @@ const validUploads = [ { url: 'https://example.com/file.pdf', id: 'with-id' }, { id: 'some-id', bucket: 'my-bucket', provider: 's3' }, { key: 'some-key', mime: 'application/pdf' }, - { url: 'https://example.com/file.pdf', bucket: 'bucket', provider: 'gcs', mime: 'application/pdf' } + { url: 'https://example.com/file.pdf', bucket: 'bucket', provider: 'gcs', mime: 'application/pdf' }, + { id: fileId, key: 'abc', mime: 'application/pdf', bucket_id: bucketId, size: 12345, filename: 'contract.pdf' }, + { id: fileId, bucket_id: bucketId.toUpperCase() } ]; const invalidUploads = [ @@ -106,6 +118,10 @@ const invalidUploads = [ { url: 'https://example.com/file.pdf', bucket: 123 }, { url: 'https://example.com/file.pdf', provider: ['array'] }, { id: 'some-id', mime: { nested: 'object' } }, + { id: fileId, bucket_id: 'not-a-uuid' }, + { id: fileId, bucket_id: bucketId.replace(/-/g, '') }, + { id: fileId, size: '12345' }, + { id: fileId, filename: 42 }, 'not-an-object', ['array-not-object'] ]; @@ -317,4 +333,47 @@ describe('types', () => { } }); }); + + describe.each(['upload', 'image'])('%s files-row projection keys', (column) => { + const insert = (value: unknown) => + pg.any(`INSERT INTO customers (${column}) VALUES ($1::json);`, [value]); + + it('accepts the full projection of a files row', async () => { + await insert({ + id: fileId, + key: 'e3b0c44298fc1c149afbf4c8996fb924', + mime: 'image/png', + bucket_id: bucketId, + size: 12345, + filename: 'hero.png' + }); + }); + + it('rejects a bucket_id that is not a uuid', async () => { + await expect(insert({ id: fileId, bucket_id: 'not-a-uuid' })).rejects.toThrow(); + await expect(insert({ id: fileId, bucket_id: '' })).rejects.toThrow(); + await expect(insert({ id: fileId, bucket_id: `${bucketId}-extra` })).rejects.toThrow(); + await expect(insert({ id: fileId, bucket_id: bucketId.replace(/-/g, '') })).rejects.toThrow(); + }); + + it('rejects a bucket_id that is not a json string', async () => { + await expect(insert({ id: fileId, bucket_id: 12345 })).rejects.toThrow(); + await expect(insert({ id: fileId, bucket_id: { uuid: bucketId } })).rejects.toThrow(); + }); + + it('keeps url-only external references valid', async () => { + await insert({ url: 'https://gravatar.com/avatar/abc', mime: 'image/png' }); + }); + + it('leaves the new keys optional', async () => { + await insert({ id: fileId }); + await insert({ key: 'some-key' }); + }); + + it('constrains size to a number and filename to a string', async () => { + await insert({ id: fileId, size: 0, filename: 'a.txt' }); + await expect(insert({ id: fileId, size: '12345' })).rejects.toThrow(); + await expect(insert({ id: fileId, filename: 42 })).rejects.toThrow(); + }); + }); }); diff --git a/packages/types/deploy/schemas/public/domains/image_file_ref_keys.sql b/packages/types/deploy/schemas/public/domains/image_file_ref_keys.sql new file mode 100644 index 00000000..d6b38b4e --- /dev/null +++ b/packages/types/deploy/schemas/public/domains/image_file_ref_keys.sql @@ -0,0 +1,23 @@ +-- Deploy schemas/public/domains/image_file_ref_keys to pg +-- requires: schemas/public/domains/image + +BEGIN; + +ALTER DOMAIN image DROP CONSTRAINT image_check; + +ALTER DOMAIN image ADD CONSTRAINT image_check CHECK ( + jsonb_typeof(value) = 'object' + AND (value ? 'url' OR value ? 'id' OR value ? 'key') + AND (NOT value ? 'url' OR (value->>'url') ~ '^https?://[^\s]+$') + AND (NOT value ? 'id' OR jsonb_typeof(value->'id') = 'string') + AND (NOT value ? 'key' OR jsonb_typeof(value->'key') = 'string') + AND (NOT value ? 'bucket' OR jsonb_typeof(value->'bucket') = 'string') + AND (NOT value ? 'bucket_id' OR (value->>'bucket_id') ~ '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') + AND (NOT value ? 'provider' OR jsonb_typeof(value->'provider') = 'string') + AND (NOT value ? 'mime' OR jsonb_typeof(value->'mime') = 'string') + AND (NOT value ? 'size' OR jsonb_typeof(value->'size') = 'number') + AND (NOT value ? 'filename' OR jsonb_typeof(value->'filename') = 'string') + AND (NOT value ? 'versions' OR jsonb_typeof(value->'versions') = 'array') +); + +COMMIT; diff --git a/packages/types/deploy/schemas/public/domains/upload_file_ref_keys.sql b/packages/types/deploy/schemas/public/domains/upload_file_ref_keys.sql new file mode 100644 index 00000000..ac707732 --- /dev/null +++ b/packages/types/deploy/schemas/public/domains/upload_file_ref_keys.sql @@ -0,0 +1,22 @@ +-- Deploy schemas/public/domains/upload_file_ref_keys to pg +-- requires: schemas/public/domains/upload + +BEGIN; + +ALTER DOMAIN upload DROP CONSTRAINT upload_check; + +ALTER DOMAIN upload ADD CONSTRAINT upload_check CHECK ( + jsonb_typeof(value) = 'object' + AND (value ? 'url' OR value ? 'id' OR value ? 'key') + AND (NOT value ? 'url' OR (value->>'url') ~ '^https?://[^\s]+$') + AND (NOT value ? 'id' OR jsonb_typeof(value->'id') = 'string') + AND (NOT value ? 'key' OR jsonb_typeof(value->'key') = 'string') + AND (NOT value ? 'bucket' OR jsonb_typeof(value->'bucket') = 'string') + AND (NOT value ? 'bucket_id' OR (value->>'bucket_id') ~ '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') + AND (NOT value ? 'provider' OR jsonb_typeof(value->'provider') = 'string') + AND (NOT value ? 'mime' OR jsonb_typeof(value->'mime') = 'string') + AND (NOT value ? 'size' OR jsonb_typeof(value->'size') = 'number') + AND (NOT value ? 'filename' OR jsonb_typeof(value->'filename') = 'string') +); + +COMMIT; diff --git a/packages/types/pgpm.plan b/packages/types/pgpm.plan index c9653e3f..0baba9f9 100644 --- a/packages/types/pgpm.plan +++ b/packages/types/pgpm.plan @@ -10,3 +10,5 @@ schemas/public/domains/image [schemas/public/schema] 2017-08-11T08:11:51Z skitch schemas/public/domains/origin [schemas/public/schema] 2017-08-11T08:11:51Z skitch # add schemas/public/domains/origin schemas/public/domains/upload [schemas/public/schema] 2017-08-11T08:11:51Z skitch # add schemas/public/domains/upload schemas/public/domains/url [schemas/public/schema] 2017-08-11T08:11:51Z skitch # add schemas/public/domains/url +schemas/public/domains/upload_file_ref_keys [schemas/public/domains/upload] 2026-08-07T00:00:00Z constructive # declare bucket_id, size and filename on upload +schemas/public/domains/image_file_ref_keys [schemas/public/domains/image] 2026-08-07T00:00:00Z constructive # declare bucket_id, size and filename on image diff --git a/packages/types/revert/schemas/public/domains/image_file_ref_keys.sql b/packages/types/revert/schemas/public/domains/image_file_ref_keys.sql new file mode 100644 index 00000000..f58f3cdb --- /dev/null +++ b/packages/types/revert/schemas/public/domains/image_file_ref_keys.sql @@ -0,0 +1,19 @@ +-- Revert schemas/public/domains/image_file_ref_keys from pg + +BEGIN; + +ALTER DOMAIN image DROP CONSTRAINT image_check; + +ALTER DOMAIN image ADD CONSTRAINT image_check CHECK ( + jsonb_typeof(value) = 'object' + AND (value ? 'url' OR value ? 'id' OR value ? 'key') + AND (NOT value ? 'url' OR (value->>'url') ~ '^https?://[^\s]+$') + AND (NOT value ? 'id' OR jsonb_typeof(value->'id') = 'string') + AND (NOT value ? 'key' OR jsonb_typeof(value->'key') = 'string') + AND (NOT value ? 'bucket' OR jsonb_typeof(value->'bucket') = 'string') + AND (NOT value ? 'provider' OR jsonb_typeof(value->'provider') = 'string') + AND (NOT value ? 'mime' OR jsonb_typeof(value->'mime') = 'string') + AND (NOT value ? 'versions' OR jsonb_typeof(value->'versions') = 'array') +); + +COMMIT; diff --git a/packages/types/revert/schemas/public/domains/upload_file_ref_keys.sql b/packages/types/revert/schemas/public/domains/upload_file_ref_keys.sql new file mode 100644 index 00000000..ab11e2d4 --- /dev/null +++ b/packages/types/revert/schemas/public/domains/upload_file_ref_keys.sql @@ -0,0 +1,18 @@ +-- Revert schemas/public/domains/upload_file_ref_keys from pg + +BEGIN; + +ALTER DOMAIN upload DROP CONSTRAINT upload_check; + +ALTER DOMAIN upload ADD CONSTRAINT upload_check CHECK ( + jsonb_typeof(value) = 'object' + AND (value ? 'url' OR value ? 'id' OR value ? 'key') + AND (NOT value ? 'url' OR (value->>'url') ~ '^https?://[^\s]+$') + AND (NOT value ? 'id' OR jsonb_typeof(value->'id') = 'string') + AND (NOT value ? 'key' OR jsonb_typeof(value->'key') = 'string') + AND (NOT value ? 'bucket' OR jsonb_typeof(value->'bucket') = 'string') + AND (NOT value ? 'provider' OR jsonb_typeof(value->'provider') = 'string') + AND (NOT value ? 'mime' OR jsonb_typeof(value->'mime') = 'string') +); + +COMMIT; diff --git a/packages/types/sql/pgpm-types--0.39.0.bundle.tar.gz b/packages/types/sql/pgpm-types--0.39.0.bundle.tar.gz index aa5ddf2c..10dfafed 100644 Binary files a/packages/types/sql/pgpm-types--0.39.0.bundle.tar.gz and b/packages/types/sql/pgpm-types--0.39.0.bundle.tar.gz differ diff --git a/packages/types/sql/pgpm-types--0.39.0.sql b/packages/types/sql/pgpm-types--0.39.0.sql index 76d524a4..327ba0ed 100644 --- a/packages/types/sql/pgpm-types--0.39.0.sql +++ b/packages/types/sql/pgpm-types--0.39.0.sql @@ -68,4 +68,62 @@ COMMENT ON DOMAIN upload IS '@name constructiveInternalTypeUpload'; CREATE DOMAIN url AS text CHECK (value ~ E'^https?://[^\\s]+$'); -COMMENT ON DOMAIN url IS '@name constructiveInternalTypeUrl'; \ No newline at end of file +COMMENT ON DOMAIN url IS '@name constructiveInternalTypeUrl'; + +ALTER DOMAIN upload DROP CONSTRAINT upload_check; + +ALTER DOMAIN upload ADD CONSTRAINT upload_check + CHECK ( + jsonb_typeof(value) = 'object' + AND (value ? 'url' + OR value ? 'id' + OR value ? 'key') + AND (NOT (value ? 'url') + OR (value ->> 'url') ~ E'^https?://[^\\s]+$') + AND (NOT (value ? 'id') + OR jsonb_typeof(value -> 'id') = 'string') + AND (NOT (value ? 'key') + OR jsonb_typeof(value -> 'key') = 'string') + AND (NOT (value ? 'bucket') + OR jsonb_typeof(value -> 'bucket') = 'string') + AND (NOT (value ? 'bucket_id') + OR (value ->> 'bucket_id') ~ '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') + AND (NOT (value ? 'provider') + OR jsonb_typeof(value -> 'provider') = 'string') + AND (NOT (value ? 'mime') + OR jsonb_typeof(value -> 'mime') = 'string') + AND (NOT (value ? 'size') + OR jsonb_typeof(value -> 'size') = 'number') + AND (NOT (value ? 'filename') + OR jsonb_typeof(value -> 'filename') = 'string') +); + +ALTER DOMAIN image DROP CONSTRAINT image_check; + +ALTER DOMAIN image ADD CONSTRAINT image_check + CHECK ( + jsonb_typeof(value) = 'object' + AND (value ? 'url' + OR value ? 'id' + OR value ? 'key') + AND (NOT (value ? 'url') + OR (value ->> 'url') ~ E'^https?://[^\\s]+$') + AND (NOT (value ? 'id') + OR jsonb_typeof(value -> 'id') = 'string') + AND (NOT (value ? 'key') + OR jsonb_typeof(value -> 'key') = 'string') + AND (NOT (value ? 'bucket') + OR jsonb_typeof(value -> 'bucket') = 'string') + AND (NOT (value ? 'bucket_id') + OR (value ->> 'bucket_id') ~ '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') + AND (NOT (value ? 'provider') + OR jsonb_typeof(value -> 'provider') = 'string') + AND (NOT (value ? 'mime') + OR jsonb_typeof(value -> 'mime') = 'string') + AND (NOT (value ? 'size') + OR jsonb_typeof(value -> 'size') = 'number') + AND (NOT (value ? 'filename') + OR jsonb_typeof(value -> 'filename') = 'string') + AND (NOT (value ? 'versions') + OR jsonb_typeof(value -> 'versions') = 'array') +); \ No newline at end of file diff --git a/packages/types/verify/schemas/public/domains/image_file_ref_keys.sql b/packages/types/verify/schemas/public/domains/image_file_ref_keys.sql new file mode 100644 index 00000000..6381eca9 --- /dev/null +++ b/packages/types/verify/schemas/public/domains/image_file_ref_keys.sql @@ -0,0 +1,29 @@ +-- Verify schemas/public/domains/image_file_ref_keys on pg + +BEGIN; + +SELECT assert_domain('public.image'::regtype, 'jsonb'::regtype, _constraints => 1); + +DO $$ +BEGIN + PERFORM '{"id": "0d1e3d64-1e2a-4c7f-9c3a-6f7f9f2b1c44", "key": "abc", "bucket_id": "9a7f1c2e-4b6d-4a11-8f30-2c5d7e9a0b13", "size": 12345, "filename": "hero.png"}'::jsonb::public.image; + + BEGIN + PERFORM '{"id": "abc", "bucket_id": "not-a-uuid"}'::jsonb::public.image; + RAISE EXCEPTION 'public.image accepted a bucket_id that is not a uuid'; + EXCEPTION WHEN check_violation THEN + NULL; + END; + + BEGIN + PERFORM '{"id": "abc", "size": "12345"}'::jsonb::public.image; + RAISE EXCEPTION 'public.image accepted a size that is not a number'; + EXCEPTION WHEN check_violation THEN + NULL; + END; + + PERFORM '{"url": "https://example.com/hero.png", "mime": "image/png"}'::jsonb::public.image; +END +$$; + +ROLLBACK; diff --git a/packages/types/verify/schemas/public/domains/upload_file_ref_keys.sql b/packages/types/verify/schemas/public/domains/upload_file_ref_keys.sql new file mode 100644 index 00000000..f3842c3f --- /dev/null +++ b/packages/types/verify/schemas/public/domains/upload_file_ref_keys.sql @@ -0,0 +1,29 @@ +-- Verify schemas/public/domains/upload_file_ref_keys on pg + +BEGIN; + +SELECT assert_domain('public.upload'::regtype, 'jsonb'::regtype, _constraints => 1); + +DO $$ +BEGIN + PERFORM '{"id": "0d1e3d64-1e2a-4c7f-9c3a-6f7f9f2b1c44", "key": "abc", "bucket_id": "9a7f1c2e-4b6d-4a11-8f30-2c5d7e9a0b13", "size": 12345, "filename": "hero.png"}'::jsonb::public.upload; + + BEGIN + PERFORM '{"id": "abc", "bucket_id": "not-a-uuid"}'::jsonb::public.upload; + RAISE EXCEPTION 'public.upload accepted a bucket_id that is not a uuid'; + EXCEPTION WHEN check_violation THEN + NULL; + END; + + BEGIN + PERFORM '{"id": "abc", "size": "12345"}'::jsonb::public.upload; + RAISE EXCEPTION 'public.upload accepted a size that is not a number'; + EXCEPTION WHEN check_violation THEN + NULL; + END; + + PERFORM '{"url": "https://example.com/hero.png", "mime": "image/png"}'::jsonb::public.upload; +END +$$; + +ROLLBACK;