simplify v2 migration history

This commit is contained in:
ajnart committed 2026-07-29 20:19:47 +02:00
1 parent 6ae109e8f5
commit 7a2d6009f7
34 files changed
+725 -9091

No files matched your search

+4
View File
@@ -42,6 +42,10 @@ pnpm dev
Homarr is available at `http://127.0.0.1:3000`. The foreground infrastructure command starts Redis and the optional
database services from `development/development.docker-compose.yml`; stop it with Ctrl+C after stopping Homarr.
The unreleased v2 Custom Widget migration history is intentionally consolidated before release. A development database
that already applied the earlier `custom_widget_v2_reset` migration is unsupported: back up anything you need, reset
only that disposable database, and run the migration command again. Homarr never deletes or resets it automatically.
## Run PocketBase and documentation locally
The integrated command starts persistent PocketBase on port 8090, waits for its health endpoint and Workshop
+7
View File
@@ -44,6 +44,13 @@ docker compose -f apps/workshop/docker-compose.yml logs -f workshop
docker compose -f apps/workshop/docker-compose.yml stop workshop
```
### Pre-release migration reset
The v2 branch consolidates the unreleased Workshop migrations into one initial migration. A PocketBase volume that ran
an earlier v2 Workshop migration chain is unsupported. Back up anything you need, then use the intentional local reset
command near the end of this document and start the stack again. Startup never deletes a volume automatically. This
exception applies only to disposable pre-release volumes and must not be used for production data.
## Connect the Homarr application
Use the same URL contract in the repository-root `.env`, then restart Homarr:
@@ -2,6 +2,41 @@
const cloneJson = (value) => JSON.parse(JSON.stringify(value));
const cloneRule = (value) => (value === null ? null : String(value));
const adminRule = "@request.auth.isAdmin = true";
const identityFieldsUnchanged = [
"@request.body.email:changed = false",
"@request.body.displayName:changed = false",
"@request.body.avatarUrl:changed = false",
"@request.body.githubUsername:changed = false",
"@request.body.githubProfileUrl:changed = false",
].join(" && ");
const immutableSubmissionFields = [
"@request.body.author:changed = false",
"@request.body.type:changed = false",
"@request.body.widgetSchema:changed = false",
].join(" && ");
const moderatorSubmissionFieldsUnchanged = [
"@request.body.title:changed = false",
"@request.body.description:changed = false",
"@request.body.content:changed = false",
"@request.body.changelog:changed = false",
"@request.body.screenshots:changed = false",
].join(" && ");
const workshopRateLimits = [
{ label: "submissions:create", audience: "@auth", duration: 60, maxRequests: 10 },
{ label: "submissions:update", audience: "@auth", duration: 60, maxRequests: 30 },
{ label: "submissions:delete", audience: "@auth", duration: 60, maxRequests: 10 },
{ label: "votes:create", audience: "@auth", duration: 10, maxRequests: 20 },
{ label: "votes:update", audience: "@auth", duration: 10, maxRequests: 20 },
{ label: "votes:delete", audience: "@auth", duration: 10, maxRequests: 20 },
{ label: "comments:create", audience: "@auth", duration: 60, maxRequests: 20 },
{ label: "comments:update", audience: "@auth", duration: 60, maxRequests: 20 },
{ label: "comments:delete", audience: "@auth", duration: 60, maxRequests: 20 },
{ label: "reports:create", audience: "@auth", duration: 60, maxRequests: 5 },
{ label: "reports:update", audience: "@auth", duration: 60, maxRequests: 30 },
{ label: "reports:delete", audience: "@auth", duration: 60, maxRequests: 30 },
{ label: "users:update", audience: "@auth", duration: 60, maxRequests: 20 },
];
migrate(
(app) => {
@@ -63,11 +98,10 @@ migrate(
addUserField(new URLField({ name: "githubProfileUrl" }));
addUserField(new BoolField({ name: "isAdmin" }));
const adminRule = "@request.auth.isAdmin = true";
users.listRule = "";
users.viewRule = "";
users.createRule = '@request.context = "oauth2" && @request.body.isAdmin:isset = false';
users.updateRule = "id = @request.auth.id && @request.body.isAdmin:isset = false";
users.updateRule = `id = @request.auth.id && @request.body.isAdmin:isset = false && ${identityFieldsUnchanged}`;
users.deleteRule = null;
app.save(users);
const stateRecord = new Record(stateCollection);
@@ -80,7 +114,9 @@ migrate(
listRule: "",
viewRule: "",
createRule: "@request.auth.id != '' && @request.body.author = @request.auth.id",
updateRule: `(author = @request.auth.id || ${adminRule}) && @request.body.author:changed = false && @request.body.type:changed = false && @request.body.widgetSchema:changed = false`,
updateRule:
`(author = @request.auth.id && ${immutableSubmissionFields}) || ` +
`(${adminRule} && author != @request.auth.id && ${immutableSubmissionFields} && ${moderatorSubmissionFieldsUnchanged})`,
deleteRule: `author = @request.auth.id || ${adminRule}`,
fields: [
{ type: "select", name: "type", required: true, maxSelect: 1, values: ["customWidget", "customCss"] },
@@ -183,7 +219,12 @@ migrate(
listRule: adminRule,
viewRule: adminRule,
createRule: "@request.auth.id != '' && @request.body.reporter = @request.auth.id",
updateRule: `${adminRule} && @request.body.reporter:changed = false && @request.body.submission:changed = false`,
updateRule:
`${adminRule} && ` +
"@request.body.reporter:changed = false && " +
"@request.body.submission:changed = false && " +
"@request.body.category:changed = false && " +
"@request.body.explanation:changed = false",
deleteRule: adminRule,
fields: [
{
@@ -243,12 +284,9 @@ migrate(
settings.rateLimits.enabled = true;
settings.rateLimits.rules = [
...settings.rateLimits.rules.filter(
(rule) => !["submissions:create", "votes:create", "comments:create", "reports:create"].includes(rule.label),
(rule) => !workshopRateLimits.some((workshopRule) => workshopRule.label === rule.label),
),
{ label: "submissions:create", audience: "@auth", duration: 60, maxRequests: 10 },
{ label: "votes:create", audience: "@auth", duration: 10, maxRequests: 20 },
{ label: "comments:create", audience: "@auth", duration: 60, maxRequests: 20 },
{ label: "reports:create", audience: "@auth", duration: 60, maxRequests: 5 },
...workshopRateLimits,
];
app.save(settings);
},
@@ -1,92 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
const managedLabels = [
"submissions:create",
"submissions:update",
"submissions:delete",
"votes:create",
"votes:update",
"votes:delete",
"comments:create",
"comments:update",
"comments:delete",
"reports:create",
"reports:update",
"reports:delete",
"users:update",
];
const legacyCreateRules = [
{ label: "submissions:create", audience: "@auth", duration: 60, maxRequests: 10 },
{ label: "votes:create", audience: "@auth", duration: 10, maxRequests: 20 },
{ label: "comments:create", audience: "@auth", duration: 60, maxRequests: 20 },
{ label: "reports:create", audience: "@auth", duration: 60, maxRequests: 5 },
];
const legacyCreateLabels = legacyCreateRules.map((rule) => rule.label);
const cloneJson = (value) => JSON.parse(JSON.stringify(value));
const withoutManagedRules = (rules) => rules.filter((rule) => !managedLabels.includes(rule.label));
const managedRules = (rules) => rules.filter((rule) => managedLabels.includes(rule.label));
const deriveLegacyWriteRateLimitState = (snapshot) => ({
enabled: true,
managedRules: managedRules([
...snapshot.rateLimits.rules.filter((rule) => !legacyCreateLabels.includes(rule.label)),
...legacyCreateRules,
]),
});
const findMigrationState = (app) => {
try {
const collection = app.findCollectionByNameOrId("workshop_migration_state");
const record = app.findFirstRecordByFilter(collection.id, "");
const snapshot = JSON.parse(record.getString("snapshot"));
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) {
throw new Error("Workshop migration state snapshot is invalid");
}
return { record, snapshot };
} catch {
throw new Error("Workshop write rate-limit migration state is unavailable");
}
};
migrate(
(app) => {
const settings = app.settings();
const state = findMigrationState(app);
state.snapshot.writeRateLimits = {
enabled: settings.rateLimits.enabled,
managedRules: cloneJson(managedRules(settings.rateLimits.rules)),
};
state.record.set("snapshot", JSON.stringify(state.snapshot));
app.save(state.record);
settings.rateLimits.enabled = true;
settings.rateLimits.rules = [
...withoutManagedRules(settings.rateLimits.rules),
{ label: "submissions:create", audience: "@auth", duration: 60, maxRequests: 10 },
{ label: "submissions:update", audience: "@auth", duration: 60, maxRequests: 30 },
{ label: "submissions:delete", audience: "@auth", duration: 60, maxRequests: 10 },
{ label: "votes:create", audience: "@auth", duration: 10, maxRequests: 20 },
{ label: "votes:update", audience: "@auth", duration: 10, maxRequests: 20 },
{ label: "votes:delete", audience: "@auth", duration: 10, maxRequests: 20 },
{ label: "comments:create", audience: "@auth", duration: 60, maxRequests: 20 },
{ label: "comments:update", audience: "@auth", duration: 60, maxRequests: 20 },
{ label: "comments:delete", audience: "@auth", duration: 60, maxRequests: 20 },
{ label: "reports:create", audience: "@auth", duration: 60, maxRequests: 5 },
{ label: "reports:update", audience: "@auth", duration: 60, maxRequests: 30 },
{ label: "reports:delete", audience: "@auth", duration: 60, maxRequests: 30 },
{ label: "users:update", audience: "@auth", duration: 60, maxRequests: 20 },
];
app.save(settings);
},
(app) => {
const settings = app.settings();
const snapshot = findMigrationState(app).snapshot;
const state = snapshot.writeRateLimits || deriveLegacyWriteRateLimitState(snapshot);
if (!state || typeof state.enabled !== "boolean" || !Array.isArray(state.managedRules)) {
throw new Error("Workshop write rate-limit rollback snapshot is missing or invalid");
}
settings.rateLimits.enabled = state.enabled;
settings.rateLimits.rules = [...withoutManagedRules(settings.rateLimits.rules), ...cloneJson(state.managedRules)];
app.save(settings);
},
);
@@ -1,65 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
const legacyUserUpdateRule = "id = @request.auth.id && @request.body.isAdmin:isset = false";
const legacySubmissionUpdateRule =
"(author = @request.auth.id || @request.auth.isAdmin = true) && @request.body.author:changed = false && @request.body.type:changed = false && @request.body.widgetSchema:changed = false";
const legacyReportUpdateRule =
"@request.auth.isAdmin = true && @request.body.reporter:changed = false && @request.body.submission:changed = false";
const identityFieldsUnchanged = [
"@request.body.email:changed = false",
"@request.body.displayName:changed = false",
"@request.body.avatarUrl:changed = false",
"@request.body.githubUsername:changed = false",
"@request.body.githubProfileUrl:changed = false",
].join(" && ");
const immutableSubmissionFields = [
"@request.body.author:changed = false",
"@request.body.type:changed = false",
"@request.body.widgetSchema:changed = false",
].join(" && ");
const moderatorSubmissionFieldsUnchanged = [
"@request.body.title:changed = false",
"@request.body.description:changed = false",
"@request.body.content:changed = false",
"@request.body.changelog:changed = false",
"@request.body.screenshots:changed = false",
].join(" && ");
migrate(
(app) => {
const users = app.findCollectionByNameOrId("users");
users.updateRule = `id = @request.auth.id && @request.body.isAdmin:isset = false && ${identityFieldsUnchanged}`;
app.save(users);
const submissions = app.findCollectionByNameOrId("submissions");
submissions.updateRule =
`(author = @request.auth.id && ${immutableSubmissionFields}) || ` +
`(@request.auth.isAdmin = true && author != @request.auth.id && ${immutableSubmissionFields} && ${moderatorSubmissionFieldsUnchanged})`;
app.save(submissions);
const reports = app.findCollectionByNameOrId("reports");
reports.updateRule =
"@request.auth.isAdmin = true && " +
"@request.body.reporter:changed = false && " +
"@request.body.submission:changed = false && " +
"@request.body.category:changed = false && " +
"@request.body.explanation:changed = false";
app.save(reports);
},
(app) => {
const users = app.findCollectionByNameOrId("users");
users.updateRule = legacyUserUpdateRule;
app.save(users);
const submissions = app.findCollectionByNameOrId("submissions");
submissions.updateRule = legacySubmissionUpdateRule;
app.save(submissions);
const reports = app.findCollectionByNameOrId("reports");
reports.updateRule = legacyReportUpdateRule;
app.save(reports);
},
);
@@ -1,61 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
const managedLabels = [
"submissions:create",
"submissions:update",
"submissions:delete",
"votes:create",
"votes:update",
"votes:delete",
"comments:create",
"comments:update",
"comments:delete",
"reports:create",
"reports:update",
"reports:delete",
"users:update",
];
const legacyCreateRules = [
{ label: "submissions:create", audience: "@auth", duration: 60, maxRequests: 10 },
{ label: "votes:create", audience: "@auth", duration: 10, maxRequests: 20 },
{ label: "comments:create", audience: "@auth", duration: 60, maxRequests: 20 },
{ label: "reports:create", audience: "@auth", duration: 60, maxRequests: 5 },
];
const legacyCreateLabels = legacyCreateRules.map((rule) => rule.label);
const managedRules = (rules) => rules.filter((rule) => managedLabels.includes(rule.label));
const findMigrationState = (app) => {
try {
const collection = app.findCollectionByNameOrId("workshop_migration_state");
const record = app.findFirstRecordByFilter(collection.id, "");
const snapshot = JSON.parse(record.getString("snapshot"));
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) {
throw new Error("Workshop migration state snapshot is invalid");
}
return { record, snapshot };
} catch {
throw new Error("Workshop rate-limit compatibility state is unavailable");
}
};
migrate(
(app) => {
const state = findMigrationState(app);
if (state.snapshot.writeRateLimits) return;
if (!state.snapshot.rateLimits || !Array.isArray(state.snapshot.rateLimits.rules)) {
throw new Error("Workshop legacy rate-limit snapshot is missing or invalid");
}
state.snapshot.writeRateLimits = {
enabled: true,
managedRules: managedRules([
...state.snapshot.rateLimits.rules.filter((rule) => !legacyCreateLabels.includes(rule.label)),
...legacyCreateRules,
]),
};
state.record.set("snapshot", JSON.stringify(state.snapshot));
app.save(state.record);
},
() => {
// Keep the backfill until 1784240001 is rolled back; 1784240000 removes the state collection afterward.
},
);
-1
View File
@@ -13,7 +13,6 @@ ROLLBACK_CONTAINER=""
node apps/workshop/tests/workshop-contracts.mjs
node apps/workshop/tests/workshop-migration.mjs
node apps/workshop/tests/workshop-rate-limit-migration.mjs
node apps/workshop/tests/entrypoint-url.mjs
node apps/workshop/tests/workshop-identity.mjs
node apps/workshop/tests/workshop-oauth-hook.mjs
+6 -27
View File
@@ -97,34 +97,13 @@ for (const required of [
if (!migration.includes(required)) throw new Error(`Workshop rollback is missing state restoration: ${required}`);
}
const hardeningMigration = await read("apps/workshop/pb_migrations/1784240001_workshop_write_rate_limits.js");
for (const operation of ["create", "update", "delete"]) {
for (const collection of ["submissions", "votes", "comments", "reports"]) {
const label = `${collection}:${operation}`;
if (!hardeningMigration.includes(label)) throw new Error(`Workshop write rate limit is missing: ${label}`);
}
}
if (!hardeningMigration.includes("users:update")) throw new Error("Workshop user file updates must be rate limited");
for (const required of [
"writeRateLimits",
"managedRules(settings.rateLimits.rules)",
"settings.rateLimits.enabled = state.enabled",
"...cloneJson(state.managedRules)",
]) {
if (!hardeningMigration.includes(required)) {
throw new Error(`Workshop write rate-limit rollback is missing persistent state restoration: ${required}`);
}
}
const accessHardeningMigration = await read("apps/workshop/pb_migrations/1784240002_workshop_access_hardening.js");
const rateLimitCompatibilityMigration = await read(
"apps/workshop/pb_migrations/1784240003_workshop_rate_limit_rollback_compatibility.js",
);
for (const required of ["writeRateLimits", "legacyCreateRules", "rate-limit compatibility state is unavailable"]) {
if (!rateLimitCompatibilityMigration.includes(required)) {
throw new Error(`Workshop rate-limit compatibility migration is missing: ${required}`);
if (!migration.includes(label)) throw new Error(`Workshop write rate limit is missing: ${label}`);
}
}
if (!migration.includes("users:update")) throw new Error("Workshop user file updates must be rate limited");
for (const protectedField of [
"email:changed",
"displayName:changed",
@@ -132,11 +111,11 @@ for (const protectedField of [
"githubUsername:changed",
"githubProfileUrl:changed",
]) {
if (!accessHardeningMigration.includes(protectedField)) {
if (!migration.includes(protectedField)) {
throw new Error(`Workshop OAuth identity field remains caller-controlled: ${protectedField}`);
}
}
if (accessHardeningMigration.includes("@request.body.avatar:changed")) {
if (migration.includes("@request.body.avatar:changed")) {
throw new Error("Workshop access rules must not use the unsupported :changed modifier for file fields");
}
if (
@@ -153,12 +132,12 @@ for (const protectedField of [
"changelog:changed",
"screenshots:changed",
]) {
if (!accessHardeningMigration.includes(protectedField)) {
if (!migration.includes(protectedField)) {
throw new Error(`Workshop moderators can still rewrite submissions: ${protectedField}`);
}
}
for (const protectedField of ["category:changed", "explanation:changed"]) {
if (!accessHardeningMigration.includes(protectedField)) {
if (!migration.includes(protectedField)) {
throw new Error(`Workshop moderators can still rewrite reports: ${protectedField}`);
}
}
@@ -1,205 +0,0 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import vm from "node:vm";
const normalized = (value) => JSON.parse(JSON.stringify(value));
const managedLabels = [
"submissions:create",
"submissions:update",
"submissions:delete",
"votes:create",
"votes:update",
"votes:delete",
"comments:create",
"comments:update",
"comments:delete",
"reports:create",
"reports:update",
"reports:delete",
"users:update",
];
const originalManagedRules = managedLabels.map((label, index) => ({
label,
audience: index % 2 === 0 ? "@auth" : "",
duration: index + 1,
maxRequests: index + 10,
}));
const originalUnmanagedRule = {
label: "unrelated:create",
audience: "",
duration: 41,
maxRequests: 42,
};
const stateCollection = { id: "workshop_migration_state" };
let stateSnapshot = JSON.stringify({ marker: "retain-existing-migration-state" });
const stateRecord = {
getString(name) {
assert.equal(name, "snapshot");
return stateSnapshot;
},
set(name, value) {
assert.equal(name, "snapshot");
stateSnapshot = value;
},
};
const settings = {
rateLimits: {
enabled: false,
rules: [originalUnmanagedRule, ...originalManagedRules],
},
};
const app = {
findCollectionByNameOrId(name) {
if (name !== stateCollection.id) throw new Error(`Missing collection ${name}`);
return stateCollection;
},
findFirstRecordByFilter(collectionId) {
if (collectionId !== stateCollection.id) throw new Error(`Missing state record ${collectionId}`);
return stateRecord;
},
save() {},
settings() {
return settings;
},
};
const loadMigration = async (path) => {
let loaded;
const source = await readFile(path, "utf8");
vm.runInNewContext(source, {
console,
migrate: (up, down) => {
loaded = { up, down };
},
});
if (!loaded) throw new Error(`Workshop migration did not register: ${path}`);
return loaded;
};
const migration = await loadMigration("apps/workshop/pb_migrations/1784240001_workshop_write_rate_limits.js");
migration.up(app);
const savedState = JSON.parse(stateSnapshot);
assert.equal(savedState.marker, "retain-existing-migration-state");
assert.deepEqual(savedState.writeRateLimits, {
enabled: false,
managedRules: originalManagedRules,
});
assert.equal(settings.rateLimits.enabled, true);
assert.deepEqual(
normalized(settings.rateLimits.rules.filter((rule) => !managedLabels.includes(rule.label))),
[originalUnmanagedRule],
"Applying Workshop rate limits changed unrelated rules",
);
assert.deepEqual(
new Set(settings.rateLimits.rules.map((rule) => rule.label)),
new Set([...managedLabels, originalUnmanagedRule.label]),
"Applying Workshop rate limits did not replace every managed label",
);
const laterUnmanagedRule = {
label: "added-after-migration:update",
audience: "@auth",
duration: 51,
maxRequests: 52,
};
settings.rateLimits.rules = [
laterUnmanagedRule,
...settings.rateLimits.rules.filter((rule) => managedLabels.includes(rule.label)),
];
migration.down(app);
assert.equal(settings.rateLimits.enabled, false, "Rollback did not restore the original enabled flag");
assert.deepEqual(
normalized(settings.rateLimits.rules.filter((rule) => !managedLabels.includes(rule.label))),
[laterUnmanagedRule],
"Rollback changed unrelated rules added after the migration",
);
assert.deepEqual(
normalized(settings.rateLimits.rules.filter((rule) => managedLabels.includes(rule.label))),
originalManagedRules,
"Rollback did not restore every original managed-label rule",
);
assert.throws(
() =>
migration.up({
...app,
findCollectionByNameOrId() {
throw new Error("missing");
},
}),
/migration state is unavailable/u,
"Migration must fail closed when its persistent rollback snapshot is unavailable",
);
const legacyOriginalRules = [
{ label: "unrelated:create", audience: "", duration: 71, maxRequests: 72 },
{ label: "submissions:create", audience: "", duration: 73, maxRequests: 74 },
{ label: "comments:update", audience: "@auth", duration: 75, maxRequests: 76 },
];
const legacyStateCollection = { id: "workshop_migration_state" };
let legacyStateSnapshot = JSON.stringify({
marker: "already-applied-old-0001",
rateLimits: { enabled: false, rules: legacyOriginalRules },
});
const legacyStateRecord = {
getString() {
return legacyStateSnapshot;
},
set(_name, value) {
legacyStateSnapshot = value;
},
};
const legacySettings = {
rateLimits: {
enabled: true,
rules: [
{ label: "unrelated:create", audience: "", duration: 71, maxRequests: 72 },
...managedLabels.map((label) => ({ label, audience: "@auth", duration: 60, maxRequests: 30 })),
],
},
};
const legacyApp = {
findCollectionByNameOrId() {
return legacyStateCollection;
},
findFirstRecordByFilter() {
return legacyStateRecord;
},
save() {},
settings() {
return legacySettings;
},
};
const compatibilityMigration = await loadMigration(
"apps/workshop/pb_migrations/1784240003_workshop_rate_limit_rollback_compatibility.js",
);
compatibilityMigration.up(legacyApp);
compatibilityMigration.down(legacyApp);
const backfilledState = JSON.parse(legacyStateSnapshot);
assert.equal(backfilledState.marker, "already-applied-old-0001");
assert.deepEqual(backfilledState.writeRateLimits, {
enabled: true,
managedRules: [
{ label: "comments:update", audience: "@auth", duration: 75, maxRequests: 76 },
{ label: "submissions:create", audience: "@auth", duration: 60, maxRequests: 10 },
{ label: "votes:create", audience: "@auth", duration: 10, maxRequests: 20 },
{ label: "comments:create", audience: "@auth", duration: 60, maxRequests: 20 },
{ label: "reports:create", audience: "@auth", duration: 60, maxRequests: 5 },
],
});
migration.down(legacyApp);
assert.equal(legacySettings.rateLimits.enabled, true);
assert.deepEqual(
normalized(legacySettings.rateLimits.rules),
[
{ label: "unrelated:create", audience: "", duration: 71, maxRequests: 72 },
...backfilledState.writeRateLimits.managedRules,
],
"An already-applied legacy migration could not be rolled back after the compatibility backfill",
);
console.log("Workshop write rate-limit migration restoration passed");
@@ -1,30 +0,0 @@
RENAME TABLE `custom_widget_definition` TO `legacy_custom_widget_definition`, `custom_widget_secret` TO `legacy_custom_widget_secret`;--> statement-breakpoint
ALTER TABLE `legacy_custom_widget_definition` DROP FOREIGN KEY `custom_widget_definition_creator_id_user_id_fk`;--> statement-breakpoint
ALTER TABLE `legacy_custom_widget_definition` ADD CONSTRAINT `legacy_cw_definition_creator_id_user_id_fk` FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `legacy_custom_widget_secret` DROP FOREIGN KEY `cw_secret_definition_id_cw_definition_id_fk`;--> statement-breakpoint
ALTER TABLE `legacy_custom_widget_secret` ADD CONSTRAINT `legacy_cw_secret_definition_id_fk` FOREIGN KEY (`definition_id`) REFERENCES `legacy_custom_widget_definition`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE TABLE `custom_widget_definition` (
`id` varchar(64) NOT NULL,
`name` varchar(256) NOT NULL,
`description` text,
`icon_url` text,
`sources` text NOT NULL,
`requests` text NOT NULL,
`options` text NOT NULL,
`template` text NOT NULL,
`enabled` boolean DEFAULT true NOT NULL,
`created_at` timestamp DEFAULT (now()) NOT NULL,
`updated_at` timestamp DEFAULT (now()) NOT NULL,
`creator_id` varchar(64),
CONSTRAINT `custom_widget_definition_id` PRIMARY KEY(`id`)
);--> statement-breakpoint
CREATE TABLE `custom_widget_secret` (
`source_id` varchar(64) NOT NULL,
`kind` varchar(64) NOT NULL,
`encrypted_value` text NOT NULL,
`updated_at` timestamp NOT NULL,
`definition_id` varchar(64) NOT NULL,
CONSTRAINT `custom_widget_secret_definition_id_source_id_kind_pk` PRIMARY KEY(`definition_id`,`source_id`,`kind`)
);--> statement-breakpoint
ALTER TABLE `custom_widget_definition` ADD CONSTRAINT `custom_widget_definition_creator_id_user_id_fk` FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `custom_widget_secret` ADD CONSTRAINT `cw_secret_definition_id_fk` FOREIGN KEY (`definition_id`) REFERENCES `custom_widget_definition`(`id`) ON DELETE cascade ON UPDATE no action;
@@ -0,0 +1,26 @@
CREATE TABLE `custom_widget_v2_definition` (
`id` varchar(64) NOT NULL,
`name` varchar(256) NOT NULL,
`description` text,
`icon_url` text,
`sources` text NOT NULL,
`requests` text NOT NULL,
`options` text NOT NULL,
`template` text NOT NULL,
`enabled` boolean DEFAULT true NOT NULL,
`created_at` timestamp DEFAULT (now()) NOT NULL,
`updated_at` timestamp DEFAULT (now()) NOT NULL,
`creator_id` varchar(64),
CONSTRAINT `custom_widget_v2_definition_id` PRIMARY KEY(`id`)
);--> statement-breakpoint
CREATE TABLE `custom_widget_v2_secret` (
`source_id` varchar(64) NOT NULL,
`kind` varchar(64) NOT NULL,
`encrypted_value` text NOT NULL,
`updated_at` timestamp NOT NULL,
`definition_id` varchar(64) NOT NULL,
CONSTRAINT `custom_widget_v2_secret_definition_id_source_id_kind_pk` PRIMARY KEY(`definition_id`,`source_id`,`kind`)
);--> statement-breakpoint
ALTER TABLE `custom_widget_v2_definition` ADD CONSTRAINT `custom_widget_v2_definition_creator_id_user_id_fk` FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `custom_widget_v2_secret` ADD CONSTRAINT `custom_widget_v2_secret_definition_id_fk` FOREIGN KEY (`definition_id`) REFERENCES `custom_widget_v2_definition`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
DELETE FROM `groupPermission` WHERE `permission` IN ('custom-widget-manage', 'custom-widget-secret-write');
@@ -1,22 +0,0 @@
RENAME TABLE
`custom_widget_definition` TO `custom_widget_v2_definition`,
`custom_widget_secret` TO `custom_widget_v2_secret`,
`legacy_custom_widget_definition` TO `custom_widget_definition`,
`legacy_custom_widget_secret` TO `custom_widget_secret`;--> statement-breakpoint
ALTER TABLE `custom_widget_v2_definition`
DROP FOREIGN KEY `custom_widget_definition_creator_id_user_id_fk`,
ADD CONSTRAINT `custom_widget_v2_definition_creator_id_user_id_fk`
FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `custom_widget_v2_secret`
DROP FOREIGN KEY `cw_secret_definition_id_fk`,
ADD CONSTRAINT `custom_widget_v2_secret_definition_id_fk`
FOREIGN KEY (`definition_id`) REFERENCES `custom_widget_v2_definition`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `custom_widget_definition`
DROP FOREIGN KEY `legacy_cw_definition_creator_id_user_id_fk`,
ADD CONSTRAINT `custom_widget_definition_creator_id_user_id_fk`
FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `custom_widget_secret`
DROP FOREIGN KEY `legacy_cw_secret_definition_id_fk`,
ADD CONSTRAINT `cw_secret_definition_id_cw_definition_id_fk`
FOREIGN KEY (`definition_id`) REFERENCES `custom_widget_definition`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
DELETE FROM `groupPermission` WHERE `permission` IN ('custom-widget-manage', 'custom-widget-secret-write');
@@ -1,7 +1,7 @@
{
"version": "5",
"dialect": "mysql",
"id": "1a10d79c-6c82-4036-8717-0283ebaece10",
"id": "e2f11bb0-59e9-4fab-8ae1-5bf764795c74",
"prevId": "073d408e-ff80-4a57-8007-f1f32da1c71c",
"tables": {
"account": {
@@ -534,198 +534,8 @@
"uniqueConstraints": {},
"checkConstraint": {}
},
"legacy_custom_widget_definition": {
"name": "legacy_custom_widget_definition",
"columns": {
"id": {
"name": "id",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"icon_url": {
"name": "icon_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"auth_type": {
"name": "auth_type",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'none'"
},
"header_name": {
"name": "header_name",
"type": "varchar(256)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"method": {
"name": "method",
"type": "varchar(16)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'GET'"
},
"request_body": {
"name": "request_body",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"display_type": {
"name": "display_type",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'singleValue'"
},
"display_config": {
"name": "display_config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "('{\"json\": {}}')"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"creator_id": {
"name": "creator_id",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
}
},
"indexes": {},
"foreignKeys": {
"legacy_cw_definition_creator_id_user_id_fk": {
"name": "legacy_cw_definition_creator_id_user_id_fk",
"tableFrom": "legacy_custom_widget_definition",
"tableTo": "user",
"columnsFrom": ["creator_id"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"legacy_custom_widget_definition_id": {
"name": "legacy_custom_widget_definition_id",
"columns": ["id"]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"legacy_custom_widget_secret": {
"name": "legacy_custom_widget_secret",
"columns": {
"kind": {
"name": "kind",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"definition_id": {
"name": "definition_id",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"legacy_cw_secret_definition_id_fk": {
"name": "legacy_cw_secret_definition_id_fk",
"tableFrom": "legacy_custom_widget_secret",
"tableTo": "legacy_custom_widget_definition",
"columnsFrom": ["definition_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"legacy_custom_widget_secret_definition_id_kind_pk": {
"name": "legacy_custom_widget_secret_definition_id_kind_pk",
"columns": ["definition_id", "kind"]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"custom_widget_definition": {
"name": "custom_widget_definition",
"custom_widget_v2_definition": {
"name": "custom_widget_v2_definition",
"columns": {
"id": {
"name": "id",
@@ -817,9 +627,9 @@
},
"indexes": {},
"foreignKeys": {
"custom_widget_definition_creator_id_user_id_fk": {
"name": "custom_widget_definition_creator_id_user_id_fk",
"tableFrom": "custom_widget_definition",
"custom_widget_v2_definition_creator_id_user_id_fk": {
"name": "custom_widget_v2_definition_creator_id_user_id_fk",
"tableFrom": "custom_widget_v2_definition",
"tableTo": "user",
"columnsFrom": ["creator_id"],
"columnsTo": ["id"],
@@ -828,16 +638,16 @@
}
},
"compositePrimaryKeys": {
"custom_widget_definition_id": {
"name": "custom_widget_definition_id",
"custom_widget_v2_definition_id": {
"name": "custom_widget_v2_definition_id",
"columns": ["id"]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"custom_widget_secret": {
"name": "custom_widget_secret",
"custom_widget_v2_secret": {
"name": "custom_widget_v2_secret",
"columns": {
"source_id": {
"name": "source_id",
@@ -877,10 +687,10 @@
},
"indexes": {},
"foreignKeys": {
"cw_secret_definition_id_fk": {
"name": "cw_secret_definition_id_fk",
"tableFrom": "custom_widget_secret",
"tableTo": "custom_widget_definition",
"custom_widget_v2_secret_definition_id_fk": {
"name": "custom_widget_v2_secret_definition_id_fk",
"tableFrom": "custom_widget_v2_secret",
"tableTo": "custom_widget_v2_definition",
"columnsFrom": ["definition_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
@@ -888,8 +698,8 @@
}
},
"compositePrimaryKeys": {
"custom_widget_secret_definition_id_source_id_kind_pk": {
"name": "custom_widget_secret_definition_id_source_id_kind_pk",
"custom_widget_v2_secret_definition_id_source_id_kind_pk": {
"name": "custom_widget_v2_secret_definition_id_source_id_kind_pk",
"columns": ["definition_id", "source_id", "kind"]
}
},
@@ -1718,6 +1528,196 @@
"uniqueConstraints": {},
"checkConstraint": {}
},
"custom_widget_definition": {
"name": "custom_widget_definition",
"columns": {
"id": {
"name": "id",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"icon_url": {
"name": "icon_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"auth_type": {
"name": "auth_type",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'none'"
},
"header_name": {
"name": "header_name",
"type": "varchar(256)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"method": {
"name": "method",
"type": "varchar(16)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'GET'"
},
"request_body": {
"name": "request_body",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"display_type": {
"name": "display_type",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'singleValue'"
},
"display_config": {
"name": "display_config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "('{\"json\": {}}')"
},
"enabled": {
"name": "enabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"creator_id": {
"name": "creator_id",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"custom_widget_definition_creator_id_user_id_fk": {
"name": "custom_widget_definition_creator_id_user_id_fk",
"tableFrom": "custom_widget_definition",
"tableTo": "user",
"columnsFrom": ["creator_id"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"custom_widget_definition_id": {
"name": "custom_widget_definition_id",
"columns": ["id"]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"custom_widget_secret": {
"name": "custom_widget_secret",
"columns": {
"kind": {
"name": "kind",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"definition_id": {
"name": "definition_id",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"cw_secret_definition_id_cw_definition_id_fk": {
"name": "cw_secret_definition_id_cw_definition_id_fk",
"tableFrom": "custom_widget_secret",
"tableTo": "custom_widget_definition",
"columnsFrom": ["definition_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"custom_widget_secret_definition_id_kind_pk": {
"name": "custom_widget_secret_definition_id_kind_pk",
"columns": ["definition_id", "kind"]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"media": {
"name": "media",
"columns": {
File diff suppressed because it is too large. Load diff
@@ -314,14 +314,7 @@
"idx": 44,
"version": "5",
"when": 1784305537309,
"tag": "0044_custom_widget_v2_reset",
"breakpoints": true
},
{
"idx": 45,
"version": "5",
"when": 1785225865332,
"tag": "0045_custom_widget_v1_compatibility",
"tag": "0044_custom_widget_v2_tables",
"breakpoints": true
}
]
@@ -1,29 +0,0 @@
ALTER TABLE "custom_widget_definition" RENAME TO "legacy_custom_widget_definition";--> statement-breakpoint
ALTER TABLE "custom_widget_secret" RENAME TO "legacy_custom_widget_secret";--> statement-breakpoint
ALTER TABLE "legacy_custom_widget_definition" RENAME CONSTRAINT "custom_widget_definition_creator_id_user_id_fk" TO "legacy_cw_definition_creator_id_user_id_fk";--> statement-breakpoint
ALTER TABLE "legacy_custom_widget_secret" RENAME CONSTRAINT "custom_widget_secret_definition_id_custom_widget_definition_id_fk" TO "legacy_cw_secret_definition_id_fk";--> statement-breakpoint
ALTER TABLE "legacy_custom_widget_secret" RENAME CONSTRAINT "custom_widget_secret_definition_id_kind_pk" TO "legacy_custom_widget_secret_definition_id_kind_pk";--> statement-breakpoint
CREATE TABLE "custom_widget_definition" (
"id" varchar(64) PRIMARY KEY NOT NULL,
"name" varchar(256) NOT NULL,
"description" text,
"icon_url" text,
"sources" text NOT NULL,
"requests" text NOT NULL,
"options" text NOT NULL,
"template" text NOT NULL,
"enabled" boolean DEFAULT true NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"creator_id" varchar(64)
);--> statement-breakpoint
CREATE TABLE "custom_widget_secret" (
"source_id" varchar(64) NOT NULL,
"kind" varchar(64) NOT NULL,
"encrypted_value" text NOT NULL,
"updated_at" timestamp NOT NULL,
"definition_id" varchar(64) NOT NULL,
CONSTRAINT "custom_widget_secret_definition_id_source_id_kind_pk" PRIMARY KEY("definition_id","source_id","kind")
);--> statement-breakpoint
ALTER TABLE "custom_widget_definition" ADD CONSTRAINT "custom_widget_definition_creator_id_user_id_fk" FOREIGN KEY ("creator_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "custom_widget_secret" ADD CONSTRAINT "cw_secret_definition_id_fk" FOREIGN KEY ("definition_id") REFERENCES "public"."custom_widget_definition"("id") ON DELETE cascade ON UPDATE no action;
@@ -0,0 +1,25 @@
CREATE TABLE "custom_widget_v2_definition" (
"id" varchar(64) PRIMARY KEY NOT NULL,
"name" varchar(256) NOT NULL,
"description" text,
"icon_url" text,
"sources" text NOT NULL,
"requests" text NOT NULL,
"options" text NOT NULL,
"template" text NOT NULL,
"enabled" boolean DEFAULT true NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"creator_id" varchar(64)
);--> statement-breakpoint
CREATE TABLE "custom_widget_v2_secret" (
"source_id" varchar(64) NOT NULL,
"kind" varchar(64) NOT NULL,
"encrypted_value" text NOT NULL,
"updated_at" timestamp NOT NULL,
"definition_id" varchar(64) NOT NULL,
CONSTRAINT "custom_widget_v2_secret_definition_id_source_id_kind_pk" PRIMARY KEY("definition_id","source_id","kind")
);--> statement-breakpoint
ALTER TABLE "custom_widget_v2_definition" ADD CONSTRAINT "custom_widget_v2_definition_creator_id_user_id_fk" FOREIGN KEY ("creator_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "custom_widget_v2_secret" ADD CONSTRAINT "custom_widget_v2_secret_definition_id_fk" FOREIGN KEY ("definition_id") REFERENCES "public"."custom_widget_v2_definition"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
DELETE FROM "groupPermission" WHERE "permission" IN ('custom-widget-manage', 'custom-widget-secret-write');
@@ -1,23 +0,0 @@
ALTER TABLE "custom_widget_definition" RENAME TO "custom_widget_v2_definition";--> statement-breakpoint
ALTER TABLE "custom_widget_secret" RENAME TO "custom_widget_v2_secret";--> statement-breakpoint
ALTER TABLE "legacy_custom_widget_definition" RENAME TO "custom_widget_definition";--> statement-breakpoint
ALTER TABLE "legacy_custom_widget_secret" RENAME TO "custom_widget_secret";--> statement-breakpoint
ALTER TABLE "custom_widget_v2_definition"
RENAME CONSTRAINT "custom_widget_definition_creator_id_user_id_fk"
TO "custom_widget_v2_definition_creator_id_user_id_fk";--> statement-breakpoint
ALTER TABLE "custom_widget_v2_secret"
RENAME CONSTRAINT "cw_secret_definition_id_fk"
TO "custom_widget_v2_secret_definition_id_fk";--> statement-breakpoint
ALTER TABLE "custom_widget_v2_secret"
RENAME CONSTRAINT "custom_widget_secret_definition_id_source_id_kind_pk"
TO "custom_widget_v2_secret_definition_id_source_id_kind_pk";--> statement-breakpoint
ALTER TABLE "custom_widget_definition"
RENAME CONSTRAINT "legacy_cw_definition_creator_id_user_id_fk"
TO "custom_widget_definition_creator_id_user_id_fk";--> statement-breakpoint
ALTER TABLE "custom_widget_secret"
RENAME CONSTRAINT "legacy_cw_secret_definition_id_fk"
TO "custom_widget_secret_definition_id_custom_widget_definition_id_fk";--> statement-breakpoint
ALTER TABLE "custom_widget_secret"
RENAME CONSTRAINT "legacy_custom_widget_secret_definition_id_kind_pk"
TO "custom_widget_secret_definition_id_kind_pk";--> statement-breakpoint
DELETE FROM "groupPermission" WHERE "permission" IN ('custom-widget-manage', 'custom-widget-secret-write');
@@ -1,5 +1,5 @@
{
"id": "f2cd8146-b478-4617-870e-f601fb437d15",
"id": "99938d86-5059-41b3-a21c-ac767440ed00",
"prevId": "97bda61f-0a71-4e41-8e60-874aafe17115",
"version": "7",
"dialect": "postgresql",
@@ -498,180 +498,8 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.legacy_custom_widget_definition": {
"name": "legacy_custom_widget_definition",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "varchar(64)",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(256)",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"icon_url": {
"name": "icon_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"auth_type": {
"name": "auth_type",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true,
"default": "'none'"
},
"header_name": {
"name": "header_name",
"type": "varchar(256)",
"primaryKey": false,
"notNull": false
},
"method": {
"name": "method",
"type": "varchar(16)",
"primaryKey": false,
"notNull": true,
"default": "'GET'"
},
"request_body": {
"name": "request_body",
"type": "text",
"primaryKey": false,
"notNull": false
},
"display_type": {
"name": "display_type",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true,
"default": "'singleValue'"
},
"display_config": {
"name": "display_config",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'{\"json\": {}}'"
},
"enabled": {
"name": "enabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"creator_id": {
"name": "creator_id",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"legacy_cw_definition_creator_id_user_id_fk": {
"name": "legacy_cw_definition_creator_id_user_id_fk",
"tableFrom": "legacy_custom_widget_definition",
"tableTo": "user",
"columnsFrom": ["creator_id"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.legacy_custom_widget_secret": {
"name": "legacy_custom_widget_secret",
"schema": "",
"columns": {
"kind": {
"name": "kind",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"definition_id": {
"name": "definition_id",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"legacy_cw_secret_definition_id_fk": {
"name": "legacy_cw_secret_definition_id_fk",
"tableFrom": "legacy_custom_widget_secret",
"tableTo": "legacy_custom_widget_definition",
"columnsFrom": ["definition_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"legacy_custom_widget_secret_definition_id_kind_pk": {
"name": "legacy_custom_widget_secret_definition_id_kind_pk",
"columns": ["definition_id", "kind"]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.custom_widget_definition": {
"name": "custom_widget_definition",
"public.custom_widget_v2_definition": {
"name": "custom_widget_v2_definition",
"schema": "",
"columns": {
"id": {
@@ -752,9 +580,9 @@
},
"indexes": {},
"foreignKeys": {
"custom_widget_definition_creator_id_user_id_fk": {
"name": "custom_widget_definition_creator_id_user_id_fk",
"tableFrom": "custom_widget_definition",
"custom_widget_v2_definition_creator_id_user_id_fk": {
"name": "custom_widget_v2_definition_creator_id_user_id_fk",
"tableFrom": "custom_widget_v2_definition",
"tableTo": "user",
"columnsFrom": ["creator_id"],
"columnsTo": ["id"],
@@ -768,8 +596,8 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.custom_widget_secret": {
"name": "custom_widget_secret",
"public.custom_widget_v2_secret": {
"name": "custom_widget_v2_secret",
"schema": "",
"columns": {
"source_id": {
@@ -805,10 +633,10 @@
},
"indexes": {},
"foreignKeys": {
"cw_secret_definition_id_fk": {
"name": "cw_secret_definition_id_fk",
"tableFrom": "custom_widget_secret",
"tableTo": "custom_widget_definition",
"custom_widget_v2_secret_definition_id_fk": {
"name": "custom_widget_v2_secret_definition_id_fk",
"tableFrom": "custom_widget_v2_secret",
"tableTo": "custom_widget_v2_definition",
"columnsFrom": ["definition_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
@@ -816,8 +644,8 @@
}
},
"compositePrimaryKeys": {
"custom_widget_secret_definition_id_source_id_kind_pk": {
"name": "custom_widget_secret_definition_id_source_id_kind_pk",
"custom_widget_v2_secret_definition_id_source_id_kind_pk": {
"name": "custom_widget_v2_secret_definition_id_source_id_kind_pk",
"columns": ["definition_id", "source_id", "kind"]
}
},
@@ -1632,6 +1460,178 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.custom_widget_definition": {
"name": "custom_widget_definition",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "varchar(64)",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(256)",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"icon_url": {
"name": "icon_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"auth_type": {
"name": "auth_type",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true,
"default": "'none'"
},
"header_name": {
"name": "header_name",
"type": "varchar(256)",
"primaryKey": false,
"notNull": false
},
"method": {
"name": "method",
"type": "varchar(16)",
"primaryKey": false,
"notNull": true,
"default": "'GET'"
},
"request_body": {
"name": "request_body",
"type": "text",
"primaryKey": false,
"notNull": false
},
"display_type": {
"name": "display_type",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true,
"default": "'singleValue'"
},
"display_config": {
"name": "display_config",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'{\"json\": {}}'"
},
"enabled": {
"name": "enabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"creator_id": {
"name": "creator_id",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"custom_widget_definition_creator_id_user_id_fk": {
"name": "custom_widget_definition_creator_id_user_id_fk",
"tableFrom": "custom_widget_definition",
"tableTo": "user",
"columnsFrom": ["creator_id"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.custom_widget_secret": {
"name": "custom_widget_secret",
"schema": "",
"columns": {
"kind": {
"name": "kind",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"definition_id": {
"name": "definition_id",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"custom_widget_secret_definition_id_custom_widget_definition_id_fk": {
"name": "custom_widget_secret_definition_id_custom_widget_definition_id_fk",
"tableFrom": "custom_widget_secret",
"tableTo": "custom_widget_definition",
"columnsFrom": ["definition_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"custom_widget_secret_definition_id_kind_pk": {
"name": "custom_widget_secret_definition_id_kind_pk",
"columns": ["definition_id", "kind"]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.media": {
"name": "media",
"schema": "",
File diff suppressed because it is too large. Load diff
@@ -76,14 +76,7 @@
"idx": 10,
"version": "7",
"when": 1784305412862,
"tag": "0010_custom_widget_v2_reset",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1785225823345,
"tag": "0011_custom_widget_v1_compatibility",
"tag": "0010_custom_widget_v2_tables",
"breakpoints": true
}
]
@@ -1,7 +1,4 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
ALTER TABLE `custom_widget_definition` RENAME TO `legacy_custom_widget_definition`;--> statement-breakpoint
ALTER TABLE `custom_widget_secret` RENAME TO `legacy_custom_widget_secret`;--> statement-breakpoint
CREATE TABLE `custom_widget_definition` (
CREATE TABLE `custom_widget_v2_definition` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`description` text,
@@ -16,13 +13,13 @@ CREATE TABLE `custom_widget_definition` (
`creator_id` text,
FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null
);--> statement-breakpoint
CREATE TABLE `custom_widget_secret` (
CREATE TABLE `custom_widget_v2_secret` (
`source_id` text NOT NULL,
`kind` text NOT NULL,
`encrypted_value` text NOT NULL,
`updated_at` integer NOT NULL,
`definition_id` text NOT NULL,
PRIMARY KEY(`definition_id`, `source_id`, `kind`),
FOREIGN KEY (`definition_id`) REFERENCES `custom_widget_definition`(`id`) ON UPDATE no action ON DELETE cascade
FOREIGN KEY (`definition_id`) REFERENCES `custom_widget_v2_definition`(`id`) ON UPDATE no action ON DELETE cascade
);--> statement-breakpoint
PRAGMA foreign_keys=ON;
DELETE FROM `groupPermission` WHERE `permission` IN ('custom-widget-manage', 'custom-widget-secret-write');
@@ -1,5 +0,0 @@
ALTER TABLE `custom_widget_secret` RENAME TO `custom_widget_v2_secret`;--> statement-breakpoint
ALTER TABLE `custom_widget_definition` RENAME TO `custom_widget_v2_definition`;--> statement-breakpoint
ALTER TABLE `legacy_custom_widget_secret` RENAME TO `custom_widget_secret`;--> statement-breakpoint
ALTER TABLE `legacy_custom_widget_definition` RENAME TO `custom_widget_definition`;--> statement-breakpoint
DELETE FROM `groupPermission` WHERE `permission` IN ('custom-widget-manage', 'custom-widget-secret-write');
@@ -1,7 +1,7 @@
{
"version": "6",
"dialect": "sqlite",
"id": "30bbf6f8-3a3c-4cb0-994e-9d8494e4fcd0",
"id": "51923913-fdc0-4ae7-9127-e86587e0b51f",
"prevId": "852e049b-72cc-4d7f-8be9-931c042fe019",
"tables": {
"account": {
@@ -515,193 +515,8 @@
"uniqueConstraints": {},
"checkConstraints": {}
},
"legacy_custom_widget_definition": {
"name": "legacy_custom_widget_definition",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"icon_url": {
"name": "icon_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"auth_type": {
"name": "auth_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'none'"
},
"header_name": {
"name": "header_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"method": {
"name": "method",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'GET'"
},
"request_body": {
"name": "request_body",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"display_type": {
"name": "display_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'singleValue'"
},
"display_config": {
"name": "display_config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'{\"json\": {}}'"
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"creator_id": {
"name": "creator_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"legacy_cw_definition_creator_id_user_id_fk": {
"name": "legacy_cw_definition_creator_id_user_id_fk",
"tableFrom": "legacy_custom_widget_definition",
"tableTo": "user",
"columnsFrom": ["creator_id"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"legacy_custom_widget_secret": {
"name": "legacy_custom_widget_secret",
"columns": {
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"definition_id": {
"name": "definition_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"legacy_cw_secret_definition_id_fk": {
"name": "legacy_cw_secret_definition_id_fk",
"tableFrom": "legacy_custom_widget_secret",
"tableTo": "legacy_custom_widget_definition",
"columnsFrom": ["definition_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"legacy_custom_widget_secret_definition_id_kind_pk": {
"columns": ["definition_id", "kind"],
"name": "legacy_custom_widget_secret_definition_id_kind_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"custom_widget_definition": {
"name": "custom_widget_definition",
"custom_widget_v2_definition": {
"name": "custom_widget_v2_definition",
"columns": {
"id": {
"name": "id",
@@ -793,9 +608,9 @@
},
"indexes": {},
"foreignKeys": {
"custom_widget_definition_creator_id_user_id_fk": {
"name": "custom_widget_definition_creator_id_user_id_fk",
"tableFrom": "custom_widget_definition",
"custom_widget_v2_definition_creator_id_user_id_fk": {
"name": "custom_widget_v2_definition_creator_id_user_id_fk",
"tableFrom": "custom_widget_v2_definition",
"tableTo": "user",
"columnsFrom": ["creator_id"],
"columnsTo": ["id"],
@@ -807,8 +622,8 @@
"uniqueConstraints": {},
"checkConstraints": {}
},
"custom_widget_secret": {
"name": "custom_widget_secret",
"custom_widget_v2_secret": {
"name": "custom_widget_v2_secret",
"columns": {
"source_id": {
"name": "source_id",
@@ -848,10 +663,10 @@
},
"indexes": {},
"foreignKeys": {
"cw_secret_definition_id_fk": {
"name": "cw_secret_definition_id_fk",
"tableFrom": "custom_widget_secret",
"tableTo": "custom_widget_definition",
"custom_widget_v2_secret_definition_id_fk": {
"name": "custom_widget_v2_secret_definition_id_fk",
"tableFrom": "custom_widget_v2_secret",
"tableTo": "custom_widget_v2_definition",
"columnsFrom": ["definition_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
@@ -859,9 +674,9 @@
}
},
"compositePrimaryKeys": {
"custom_widget_secret_definition_id_source_id_kind_pk": {
"custom_widget_v2_secret_definition_id_source_id_kind_pk": {
"columns": ["definition_id", "source_id", "kind"],
"name": "custom_widget_secret_definition_id_source_id_kind_pk"
"name": "custom_widget_v2_secret_definition_id_source_id_kind_pk"
}
},
"uniqueConstraints": {},
@@ -1656,6 +1471,191 @@
"uniqueConstraints": {},
"checkConstraints": {}
},
"custom_widget_definition": {
"name": "custom_widget_definition",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"icon_url": {
"name": "icon_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"auth_type": {
"name": "auth_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'none'"
},
"header_name": {
"name": "header_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"method": {
"name": "method",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'GET'"
},
"request_body": {
"name": "request_body",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"display_type": {
"name": "display_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'singleValue'"
},
"display_config": {
"name": "display_config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'{\"json\": {}}'"
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"creator_id": {
"name": "creator_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"custom_widget_definition_creator_id_user_id_fk": {
"name": "custom_widget_definition_creator_id_user_id_fk",
"tableFrom": "custom_widget_definition",
"tableTo": "user",
"columnsFrom": ["creator_id"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"custom_widget_secret": {
"name": "custom_widget_secret",
"columns": {
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"definition_id": {
"name": "definition_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"custom_widget_secret_definition_id_custom_widget_definition_id_fk": {
"name": "custom_widget_secret_definition_id_custom_widget_definition_id_fk",
"tableFrom": "custom_widget_secret",
"tableTo": "custom_widget_definition",
"columnsFrom": ["definition_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"custom_widget_secret_definition_id_kind_pk": {
"columns": ["definition_id", "kind"],
"name": "custom_widget_secret_definition_id_kind_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"media": {
"name": "media",
"columns": {
File diff suppressed because it is too large. Load diff
@@ -300,14 +300,7 @@
"idx": 42,
"version": "6",
"when": 1784305283910,
"tag": "0042_custom_widget_v2_reset",
"breakpoints": true
},
{
"idx": 43,
"version": "6",
"when": 1785225790467,
"tag": "0043_custom_widget_v1_compatibility",
"tag": "0042_custom_widget_v2_tables",
"breakpoints": true
}
]
+4 -5
View File
@@ -60,7 +60,7 @@ describe("Mysql Migration", () => {
await mysqlContainer.stop();
}, 90_000);
test("preserves v1 and already-created v2 custom widgets, secrets, and board references", async () => {
test("Custom Widget v2 migration preserves v1 data and board references", async () => {
const mysqlContainer = await new MySqlContainer("mysql:latest").start();
const connection = mysql.createConnection({
host: mysqlContainer.getHost(),
@@ -132,20 +132,19 @@ describe("Mysql Migration", () => {
legacyPlacementOptions,
]);
await applyMigration(connection, "0044_custom_widget_v2_reset.sql");
await applyMigration(connection, "0044_custom_widget_v2_tables.sql");
await sql.query(`
INSERT INTO \`custom_widget_definition\` (
INSERT INTO \`custom_widget_v2_definition\` (
\`id\`, \`name\`, \`sources\`, \`requests\`, \`options\`, \`template\`, \`enabled\`,
\`created_at\`, \`updated_at\`, \`creator_id\`
) VALUES (
'legacy-weather', 'Weather v2', '[]', '[]', '[]', '<Text>Weather</Text>', true,
'2023-11-14 00:00:02', '2023-11-14 00:00:03', 'owner'
);
INSERT INTO \`custom_widget_secret\` (
INSERT INTO \`custom_widget_v2_secret\` (
\`source_id\`, \`kind\`, \`encrypted_value\`, \`updated_at\`, \`definition_id\`
) VALUES ('weather', 'apiKey', 'encrypted.v2-value', '2023-11-14 00:00:03', 'legacy-weather');
`);
await applyMigration(connection, "0045_custom_widget_v1_compatibility.sql");
const [definitions] = await sql.query<mysql.RowDataPacket[]>(
"SELECT id, name, url, enabled, creator_id FROM custom_widget_definition",
@@ -70,7 +70,7 @@ describe("PostgreSql Migration", () => {
await postgreSqlContainer.stop();
}, 40_000);
test("preserves v1 and already-created v2 custom widgets, secrets, and board references", async () => {
test("Custom Widget v2 migration preserves v1 data and board references", async () => {
const postgreSqlContainer = await new PostgreSqlContainer("postgres:latest").start();
const pool = new Pool({
user: postgreSqlContainer.getUsername(),
@@ -140,20 +140,19 @@ describe("PostgreSql Migration", () => {
legacyPlacementOptions,
]);
await applyMigration(pool, "0010_custom_widget_v2_reset.sql");
await applyMigration(pool, "0010_custom_widget_v2_tables.sql");
await pool.query(`
INSERT INTO "custom_widget_definition" (
INSERT INTO "custom_widget_v2_definition" (
"id", "name", "sources", "requests", "options", "template", "enabled",
"created_at", "updated_at", "creator_id"
) VALUES (
'legacy-weather', 'Weather v2', '[]', '[]', '[]', '<Text>Weather</Text>', true,
'2023-11-14 00:00:02', '2023-11-14 00:00:03', 'owner'
);
INSERT INTO "custom_widget_secret" (
INSERT INTO "custom_widget_v2_secret" (
"source_id", "kind", "encrypted_value", "updated_at", "definition_id"
) VALUES ('weather', 'apiKey', 'encrypted.v2-value', '2023-11-14 00:00:03', 'legacy-weather');
`);
await applyMigration(pool, "0011_custom_widget_v1_compatibility.sql");
expect(
(await pool.query("SELECT id, name, url, enabled, creator_id FROM custom_widget_definition")).rows,
+4 -5
View File
@@ -45,7 +45,7 @@ test("SQLite migrations seed the five disabled bundled custom widgets", async ()
connection.close();
});
test("Custom JSX compatibility migration preserves v1 and already-created v2 data", () => {
test("Custom Widget v2 migration preserves v1 data and board references", () => {
const connection = new BetterSqlite3(":memory:");
const legacyPlacementOptions = SuperJSON.stringify({ definitionId: "legacy-weather", refreshInterval: 30 });
connection.exec(`
@@ -99,18 +99,17 @@ test("Custom JSX compatibility migration preserves v1 and already-created v2 dat
.prepare("INSERT INTO item (id, kind, options) VALUES ('weather-item', 'customApi', ?)")
.run(legacyPlacementOptions);
applyMigration(connection, "0042_custom_widget_v2_reset.sql");
applyMigration(connection, "0042_custom_widget_v2_tables.sql");
connection.exec(`
INSERT INTO custom_widget_definition (
INSERT INTO custom_widget_v2_definition (
id, name, sources, requests, options, template, enabled, created_at, updated_at, creator_id
) VALUES (
'legacy-weather', 'Weather v2', '[]', '[]', '[]', '<Text>Weather</Text>', true,
1700000002, 1700000003, 'owner'
);
INSERT INTO custom_widget_secret (source_id, kind, encrypted_value, updated_at, definition_id)
INSERT INTO custom_widget_v2_secret (source_id, kind, encrypted_value, updated_at, definition_id)
VALUES ('weather', 'apiKey', 'encrypted.v2-value', 1700000003, 'legacy-weather');
`);
applyMigration(connection, "0043_custom_widget_v1_compatibility.sql");
expect(connection.prepare("SELECT * FROM custom_widget_definition").get()).toMatchObject({
id: "legacy-weather",
-1
View File
@@ -29,7 +29,6 @@
"@tanstack/react-query": "catalog:",
"pocketbase": "catalog:",
"react": "catalog:",
"tr46": "6.0.0",
"zod": "catalog:"
},
"devDependencies": {
@@ -1,215 +0,0 @@
import { describe, expect, test } from "vitest";
import { CUSTOM_WIDGET_STARTER, customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
import { PocketBaseUrl } from "./pocketbase-url";
describe("PocketBase URL compatibility", () => {
test.each([
"https://example.com",
"https://example.com.",
"https://münich.example",
"https://xn--mnich-kva.example",
"http://example.com:0",
"http://example.com:",
"http://example.com:80",
"http://[::1]:8090",
"http://[::ffff:192.168.1.1]",
])("accepts canonical URL %s", (value) => {
expect(URL.canParse(value)).toBe(true);
expect(PocketBaseUrl.canParse(value)).toBe(true);
});
test.each([
"http://",
"https://:",
"http://1.2.3.4.5",
"http://09.0.0.1",
"http://099.0.0.1",
"http://[:::1]",
"http://[:1:2:3:4:5:6:7:8]",
"http://[1:2:3:4:5:6:7:8:]",
"http://[192.168.1.1::]",
"http://[::ffff:192.168.001.001]",
"http://[::ffff:192.168.1.01]",
"http://[fe80::1%25eth0]",
"http://example.com:65536",
"https://xn--a.example",
"https://xn--0.example",
"https://xn--abc.example",
])("rejects canonical invalid URL %s", (value) => {
expect(URL.canParse(value)).toBe(false);
expect(PocketBaseUrl.canParse(value)).toBe(false);
});
test.each([
"http://127.1",
"http://2130706433",
"http://0x7f000001",
"http://0177.0.0.1",
"http://1.2.3",
"http://%31%32%37.0.0.1",
"https://%65xample.com",
"https://example%2ecom",
"https://example.com\\@attacker.invalid",
"https:\\\\example.com\\api",
"https://-example.com",
"https://example-.com",
"https://example..com",
"https://example.com:00080",
"https://example.com:0000000065535",
"https://example。com",
"https://127.0.0.1",
" https://example.com",
"https://exa\tmple.com",
"https://example.com/path\u00a0",
])("conservatively rejects WHATWG-normalized URL %s", (value) => {
expect(URL.canParse(value)).toBe(true);
expect(PocketBaseUrl.canParse(value)).toBe(false);
});
test("normalizes international hostnames like the native parser", () => {
expect(new PocketBaseUrl("https://münich.example").hostname).toBe("xn--mnich-kva.example");
expect(new PocketBaseUrl("https://例え.テスト.").hostname).toBe("xn--r8jz45g.xn--zckzah.");
});
test("normalizes IPv6 hostnames like the native parser", () => {
expect(new PocketBaseUrl("http://[1:2:3:4:5:6:7::]").hostname).toBe("[1:2:3:4:5:6:7:0]");
expect(new PocketBaseUrl("http://[::ffff:192.168.1.1]").hostname).toBe("[::ffff:c0a8:101]");
});
test.each([
"https://example.com",
"https://example.com.",
"https://münich.example",
"https://例え.テスト",
"https://xn--r8jz45g.xn--zckzah",
"http://192.168.1.1:0",
"https://[2001:db8::1]:65535",
"https://[::ffff:192.168.1.1]",
"http://0.0.0.0",
"http://255.255.255.255",
"https://example.com:",
"https://example.com:0",
"https://example.com:65535",
"http://[::]",
"http://[::1]",
"http://[1:2:3:4:5:6:7:8]",
"http://[1:2:3:4:5:6:7::]",
"https://faß.de",
"https://xn--fa-hia.de",
"https://مثال.إختبار",
"https://a_b.example",
"https://example.com/%5c@evil",
"https://example.com/%2f%2fevil",
"https://example.com/icon.svg?next=https%3A%2F%2Fevil#preview",
"http://127.1",
"http://2130706433",
"http://0x7f000001",
"http://0xffffffff",
"http://0x100000000",
"http://0177.0.0.1",
"http://0377.0377.0377.0377",
"http://1.2.3",
"http://1.",
"http://1..2",
"http://256.0.0.1",
"http://00.0.0.0",
"http://%31%32%37.0.0.1",
"https://%65xample.com",
"https://example%2ecom",
"https://example.com\\@attacker.invalid",
"https:\\\\example.com\\api",
" https://example.com",
"https://exa\tmple.com",
"https://example.com\n",
"https://example.com\u00a0",
"https://@example.com",
"https://:@example.com",
"https://example。com",
"https://127.0.0.1",
"https://-example.com",
"https://example-.com",
"https://example..com",
"https://example.com:01",
"https://example.com:00080",
"https://example.com:0000000065535",
"https://example.com:65536",
"https://example.com:655350",
"https://[fe80::1%25eth0]",
"https://[::ffff:192.168.001.001]",
"https://[1:2:3:4:5:6:7:8:9]",
"https://\u200d.example",
"http:example.com",
"https:/example.com",
"https:///example.com",
"ftp://example.com",
"file:///tmp/icon.svg",
"https://user:password@example.com",
])("keeps canonical source and icon validation in parity for %s", (value) => {
const validate = (UrlImplementation: typeof URL, field: "source" | "icon") => {
const runtime = globalThis as unknown as { URL: typeof URL };
const originalUrl = runtime.URL;
runtime.URL = UrlImplementation;
try {
const definition =
field === "source"
? {
...CUSTOM_WIDGET_STARTER,
sources: {
default: { ...CUSTOM_WIDGET_STARTER.sources.default, baseUrl: value },
},
}
: { ...CUSTOM_WIDGET_STARTER, iconUrl: value };
return customWidgetDefinitionSchema.safeParse(definition).success;
} finally {
runtime.URL = originalUrl;
}
};
const nativeUrl = URL;
expect(validate(PocketBaseUrl as unknown as typeof URL, "source")).toBe(validate(nativeUrl, "source"));
expect(validate(PocketBaseUrl as unknown as typeof URL, "icon")).toBe(validate(nativeUrl, "icon"));
});
test("exposes the URL fields used by the canonical schema", () => {
const url = new PocketBaseUrl("https://user:password@example.com:8443/icon.svg?token=secret&mode=dark#icon");
expect(url).toMatchObject({
protocol: "https:",
username: "user",
password: "password",
hostname: "example.com",
port: "8443",
search: "?token=secret&mode=dark",
hash: "#icon",
});
expect(url.searchParams.keys()).toEqual(["token", "mode"]);
});
test("validates credential queries without iterable URLSearchParams", () => {
const runtime = globalThis as unknown as { URL: typeof URL };
const originalUrl = runtime.URL;
runtime.URL = PocketBaseUrl as unknown as typeof URL;
try {
expect(
customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
iconUrl: "https://example.com/icon.svg?clientSecret=must-never-be-published",
}).success,
).toBe(false);
expect(
customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
requests: {
default: {
...CUSTOM_WIDGET_STARTER.requests.default,
path: "/api/status?key=status&auth=none",
},
},
}).success,
).toBe(true);
} finally {
runtime.URL = originalUrl;
}
});
});
-259
View File
@@ -1,259 +0,0 @@
import { toASCII } from "tr46";
interface PortableSearchParams {
keys(): string[];
}
interface ParsedAuthority {
username: string;
password: string;
hostname: string;
port: string;
}
/**
* PocketBase's Goja runtime intentionally has no browser URL global. The
* canonical Custom Widget schema uses the small subset implemented here.
*/
export class PocketBaseUrl {
public readonly protocol: string;
public readonly username: string;
public readonly password: string;
public readonly hostname: string;
public readonly port: string;
public readonly search: string;
public readonly hash: string;
public readonly origin: string;
public readonly searchParams: PortableSearchParams;
public static canParse(value: unknown): boolean {
try {
const parsed = new PocketBaseUrl(value);
void parsed;
return true;
} catch {
return false;
}
}
public constructor(value: unknown) {
if (typeof value !== "string" || hasForbiddenUrlCharacter(value) || value.includes("\\")) {
throw new TypeError("Invalid URL");
}
const scheme = value.match(/^([A-Za-z][A-Za-z0-9+.-]*):/u);
if (!scheme?.[1]) throw new TypeError("Invalid URL");
this.protocol = `${scheme[1].toLowerCase()}:`;
const afterScheme = value.slice(scheme[0].length);
const hashIndex = afterScheme.indexOf("#");
const withoutHash = hashIndex < 0 ? afterScheme : afterScheme.slice(0, hashIndex);
this.hash = hashIndex < 0 ? "" : afterScheme.slice(hashIndex);
const searchIndex = withoutHash.indexOf("?");
const withoutSearch = searchIndex < 0 ? withoutHash : withoutHash.slice(0, searchIndex);
this.search = searchIndex < 0 ? "" : withoutHash.slice(searchIndex);
this.searchParams = createSearchParams(this.search);
const requiresAuthority = this.protocol === "http:" || this.protocol === "https:" || this.protocol === "ftp:";
if (!withoutSearch.startsWith("//")) {
if (requiresAuthority) throw new TypeError("Invalid URL");
this.username = "";
this.password = "";
this.hostname = "";
this.port = "";
this.origin = "null";
return;
}
const authorityAndPath = withoutSearch.slice(2);
const slashIndex = authorityAndPath.indexOf("/");
const authority = slashIndex < 0 ? authorityAndPath : authorityAndPath.slice(0, slashIndex);
const parsed = parseAuthority(authority);
this.username = parsed.username;
this.password = parsed.password;
this.hostname = parsed.hostname;
this.port = parsed.port;
this.origin = `${this.protocol}//${this.hostname}${this.port ? `:${this.port}` : ""}`;
}
}
export function installPocketBaseUrlPolyfill(): void {
const runtime = globalThis as unknown as Record<string, unknown>;
if (typeof runtime.URL === "undefined") runtime.URL = PocketBaseUrl;
}
function parseAuthority(authority: string): ParsedAuthority {
if (!authority || /[。.。]/u.test(authority)) throw new TypeError("Invalid URL");
const atIndex = authority.lastIndexOf("@");
const credentials = atIndex < 0 ? "" : authority.slice(0, atIndex);
const hostAndPort = atIndex < 0 ? authority : authority.slice(atIndex + 1);
if (!hostAndPort) throw new TypeError("Invalid URL");
const credentialSeparator = credentials.indexOf(":");
const username = credentialSeparator < 0 ? credentials : credentials.slice(0, credentialSeparator);
const password = credentialSeparator < 0 ? "" : credentials.slice(credentialSeparator + 1);
let hostname: string;
let port = "";
if (hostAndPort.startsWith("[")) {
const closeIndex = hostAndPort.indexOf("]");
if (closeIndex < 0) throw new TypeError("Invalid URL");
const address = hostAndPort.slice(1, closeIndex);
const normalizedAddress = normalizeIpv6(address);
if (normalizedAddress === null) throw new TypeError("Invalid URL");
hostname = `[${normalizedAddress}]`;
const suffix = hostAndPort.slice(closeIndex + 1);
if (suffix) {
if (!suffix.startsWith(":")) throw new TypeError("Invalid URL");
port = suffix.length === 1 ? "" : validatePort(suffix.slice(1));
}
} else {
const firstColon = hostAndPort.indexOf(":");
const lastColon = hostAndPort.lastIndexOf(":");
if (firstColon !== lastColon) throw new TypeError("Invalid URL");
hostname = firstColon < 0 ? hostAndPort : hostAndPort.slice(0, firstColon);
const portValue = firstColon < 0 ? "" : hostAndPort.slice(firstColon + 1);
port = portValue ? validatePort(portValue) : "";
const normalizedHostname = normalizeHostname(hostname);
if (!normalizedHostname) throw new TypeError("Invalid URL");
hostname = normalizedHostname;
}
return { username, password, hostname: hostname.toLowerCase(), port };
}
function validatePort(value: string): string {
if (!/^(?:0|[1-9]\d{0,4})$/u.test(value)) throw new TypeError("Invalid URL");
const port = Number(value);
if (!Number.isInteger(port) || port > 65_535) throw new TypeError("Invalid URL");
return String(port);
}
function normalizeHostname(value: string): string | null {
const ascii = toASCII(value, {
checkHyphens: false,
checkBidi: true,
checkJoiners: true,
useSTD3ASCIIRules: false,
transitionalProcessing: false,
verifyDNSLength: false,
ignoreInvalidPunycode: false,
});
if (!ascii || hasForbiddenUrlCharacter(ascii) || /[#%/:<>?@[\\\]^|]/u.test(ascii)) return null;
const candidate = ascii.endsWith(".") ? ascii.slice(0, -1) : ascii;
if (!candidate || candidate.length > 253 || !/^[A-Za-z0-9._-]+$/u.test(candidate)) return null;
if (looksNumeric(candidate)) {
const rawCandidate = value.endsWith(".") ? value.slice(0, -1) : value;
return isStrictIpv4(candidate) && rawCandidate === candidate ? ascii.toLowerCase() : null;
}
const isValid = candidate
.split(".")
.every((part) => Boolean(part) && part.length <= 63 && !part.startsWith("-") && !part.endsWith("-"));
return isValid ? ascii.toLowerCase() : null;
}
function hasForbiddenUrlCharacter(value: string): boolean {
return [...value].some((character) => {
const code = character.codePointAt(0) ?? 0;
return (
code <= 0x20 ||
(code >= 0x7f && code <= 0xa0) ||
code === 0x1680 ||
(code >= 0x2000 && code <= 0x200f) ||
(code >= 0x2028 && code <= 0x202f) ||
(code >= 0x205f && code <= 0x206f) ||
code === 0x3000 ||
code === 0xfeff
);
});
}
function normalizeIpv6(value: string): string | null {
if (value.includes("%")) return null;
const address = value;
if (!address.includes(":") || address.includes(":::") || /[^0-9A-Fa-f:.]/u.test(address)) return null;
if (address.indexOf("::") !== address.lastIndexOf("::")) return null;
if ((address.startsWith(":") && !address.startsWith("::")) || (address.endsWith(":") && !address.endsWith("::"))) {
return null;
}
const hasCompression = address.includes("::");
const [left = "", right = ""] = hasCompression ? address.split("::") : [address, ""];
if (hasCompression && left.includes(".")) return null;
const leftGroups = parseIpv6Groups(left);
const rightGroups = parseIpv6Groups(right);
if (leftGroups === null || rightGroups === null) return null;
const explicitGroupCount = leftGroups.length + rightGroups.length;
if ((!hasCompression && explicitGroupCount !== 8) || (hasCompression && explicitGroupCount >= 8)) return null;
const groups = hasCompression
? [...leftGroups, ...Array.from({ length: 8 - explicitGroupCount }, () => 0), ...rightGroups]
: leftGroups;
let bestStart = -1;
let bestLength = 1;
for (let index = 0; index < groups.length;) {
if (groups[index] !== 0) {
index += 1;
continue;
}
let end = index + 1;
while (end < groups.length && groups[end] === 0) end += 1;
if (end - index > bestLength) {
bestStart = index;
bestLength = end - index;
}
index = end;
}
const hexadecimalGroups = groups.map((group) => group.toString(16));
if (bestStart < 0) return hexadecimalGroups.join(":");
const before = hexadecimalGroups.slice(0, bestStart).join(":");
const after = hexadecimalGroups.slice(bestStart + bestLength).join(":");
return `${before}::${after}`;
}
function parseIpv6Groups(value: string): number[] | null {
if (!value) return [];
const groups = value.split(":");
const parsed: number[] = [];
for (const [index, group] of groups.entries()) {
if (group.includes(".")) {
if (index !== groups.length - 1 || !isStrictIpv4(group)) return null;
const parts = group.split(".").map(Number);
parsed.push(((parts[0] ?? 0) << 8) | (parts[1] ?? 0), ((parts[2] ?? 0) << 8) | (parts[3] ?? 0));
} else {
if (!/^[0-9A-Fa-f]{1,4}$/u.test(group)) return null;
parsed.push(Number.parseInt(group, 16));
}
}
return parsed;
}
function isStrictIpv4(value: string): boolean {
const parts = value.split(".");
return parts.length === 4 && parts.every((part) => /^(?:0|[1-9]\d{0,2})$/u.test(part) && Number(part) <= 255);
}
function looksNumeric(value: string): boolean {
return value.split(".").every((part) => /^(?:\d+|0[xX][0-9A-Fa-f]+)$/u.test(part));
}
function createSearchParams(search: string): PortableSearchParams {
const keys = search
? search
.slice(1)
.split("&")
.map((entry) => entry.split("=", 1)[0] ?? "")
.map(decodeQueryPart)
: [];
return { keys: () => [...keys] };
}
function decodeQueryPart(value: string): string {
try {
return decodeURIComponent(value.replaceAll("+", " "));
} catch {
return value;
}
}
-13
View File
@@ -1,13 +0,0 @@
declare module "tr46" {
interface ToAsciiOptions {
checkHyphens?: boolean;
checkBidi?: boolean;
checkJoiners?: boolean;
useSTD3ASCIIRules?: boolean;
transitionalProcessing?: boolean;
verifyDNSLength?: boolean;
ignoreInvalidPunycode?: boolean;
}
export function toASCII(value: string, options?: ToAsciiOptions): string | null;
}
-3
View File
@@ -2949,9 +2949,6 @@ importers:
pocketbase:
specifier: 'catalog:'
version: 0.26.9
tr46:
specifier: 6.0.0
version: 6.0.0
react:
specifier: 'catalog:'
version: 19.2.7