@hot-updater/postgres 0.1.5 → 0.1.6-0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -46,18 +46,20 @@ var postgres = (config, hooks) => (_) => {
46
46
  tx.insertInto("bundles").values({
47
47
  id: bundle.id,
48
48
  enabled: bundle.enabled,
49
- file: bundle.file,
49
+ file_url: bundle.fileUrl,
50
50
  force_update: bundle.forceUpdate,
51
- hash: bundle.hash,
51
+ file_hash: bundle.fileHash,
52
+ git_commit_hash: bundle.gitCommitHash,
52
53
  message: bundle.message,
53
54
  platform: bundle.platform,
54
55
  target_version: bundle.targetVersion
55
56
  }).onConflict(
56
57
  (oc) => oc.column("id").doUpdateSet({
57
58
  enabled: bundle.enabled,
58
- file: bundle.file,
59
+ file_url: bundle.fileUrl,
59
60
  force_update: bundle.forceUpdate,
60
- hash: bundle.hash,
61
+ file_hash: bundle.fileHash,
62
+ git_commit_hash: bundle.gitCommitHash,
61
63
  message: bundle.message,
62
64
  platform: bundle.platform,
63
65
  target_version: bundle.targetVersion
@@ -89,9 +91,10 @@ var postgres = (config, hooks) => (_) => {
89
91
  }
90
92
  return {
91
93
  enabled: data.enabled,
92
- file: data.file,
94
+ fileUrl: data.file_url,
93
95
  forceUpdate: data.force_update,
94
- hash: data.hash,
96
+ fileHash: data.file_hash,
97
+ gitCommitHash: data.git_commit_hash,
95
98
  id: data.id,
96
99
  message: data.message,
97
100
  platform: data.platform,
@@ -105,9 +108,10 @@ var postgres = (config, hooks) => (_) => {
105
108
  const data = await db.selectFrom("bundles").selectAll().execute();
106
109
  return data.map((bundle) => ({
107
110
  enabled: bundle.enabled,
108
- file: bundle.file,
111
+ fileUrl: bundle.file_url,
109
112
  forceUpdate: bundle.force_update,
110
- hash: bundle.hash,
113
+ fileHash: bundle.file_hash,
114
+ gitCommitHash: bundle.git_commit_hash,
111
115
  id: bundle.id,
112
116
  message: bundle.message,
113
117
  platform: bundle.platform,
package/dist/index.js CHANGED
@@ -20,18 +20,20 @@ var postgres = (config, hooks) => (_) => {
20
20
  tx.insertInto("bundles").values({
21
21
  id: bundle.id,
22
22
  enabled: bundle.enabled,
23
- file: bundle.file,
23
+ file_url: bundle.fileUrl,
24
24
  force_update: bundle.forceUpdate,
25
- hash: bundle.hash,
25
+ file_hash: bundle.fileHash,
26
+ git_commit_hash: bundle.gitCommitHash,
26
27
  message: bundle.message,
27
28
  platform: bundle.platform,
28
29
  target_version: bundle.targetVersion
29
30
  }).onConflict(
30
31
  (oc) => oc.column("id").doUpdateSet({
31
32
  enabled: bundle.enabled,
32
- file: bundle.file,
33
+ file_url: bundle.fileUrl,
33
34
  force_update: bundle.forceUpdate,
34
- hash: bundle.hash,
35
+ file_hash: bundle.fileHash,
36
+ git_commit_hash: bundle.gitCommitHash,
35
37
  message: bundle.message,
36
38
  platform: bundle.platform,
37
39
  target_version: bundle.targetVersion
@@ -63,9 +65,10 @@ var postgres = (config, hooks) => (_) => {
63
65
  }
64
66
  return {
65
67
  enabled: data.enabled,
66
- file: data.file,
68
+ fileUrl: data.file_url,
67
69
  forceUpdate: data.force_update,
68
- hash: data.hash,
70
+ fileHash: data.file_hash,
71
+ gitCommitHash: data.git_commit_hash,
69
72
  id: data.id,
70
73
  message: data.message,
71
74
  platform: data.platform,
@@ -79,9 +82,10 @@ var postgres = (config, hooks) => (_) => {
79
82
  const data = await db.selectFrom("bundles").selectAll().execute();
80
83
  return data.map((bundle) => ({
81
84
  enabled: bundle.enabled,
82
- file: bundle.file,
85
+ fileUrl: bundle.file_url,
83
86
  forceUpdate: bundle.force_update,
84
- hash: bundle.hash,
87
+ fileHash: bundle.file_hash,
88
+ gitCommitHash: bundle.git_commit_hash,
85
89
  id: bundle.id,
86
90
  message: bundle.message,
87
91
  platform: bundle.platform,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hot-updater/postgres",
3
3
  "type": "module",
4
- "version": "0.1.5",
4
+ "version": "0.1.6-0",
5
5
  "description": "React Native OTA solution for self-hosted",
6
6
  "main": "dist/index.cjs",
7
7
  "module": "dist/index.js",
@@ -18,15 +18,19 @@
18
18
  },
19
19
  "files": [
20
20
  "dist",
21
+ "sql",
21
22
  "package.json"
22
23
  ],
23
24
  "dependencies": {
24
- "@hot-updater/plugin-core": "0.1.5",
25
+ "@hot-updater/core": "0.1.6-0",
26
+ "@hot-updater/plugin-core": "0.1.6-0",
25
27
  "kysely": "^0.27.4",
26
28
  "pg": "^8.13.1"
27
29
  },
28
30
  "devDependencies": {
29
- "@types/pg": "^8.11.10"
31
+ "@electric-sql/pglite": "^0.2.15",
32
+ "@types/pg": "^8.11.10",
33
+ "camelcase-keys": "^9.1.3"
30
34
  },
31
35
  "scripts": {
32
36
  "build": "tsup src/index.ts --format cjs,esm --dts",
@@ -0,0 +1,13 @@
1
+ CREATE TYPE platforms AS ENUM ('ios', 'android');
2
+
3
+ CREATE TABLE bundles (
4
+ id uuid PRIMARY KEY,
5
+ platform platforms NOT NULL,
6
+ target_version text NOT NULL,
7
+ force_update boolean NOT NULL,
8
+ enabled boolean NOT NULL,
9
+ file_url text NOT NULL,
10
+ file_hash text NOT NULL,
11
+ git_commit_hash text,
12
+ message text
13
+ );
@@ -0,0 +1,79 @@
1
+ CREATE OR REPLACE FUNCTION get_update_info (
2
+ current_platform platforms,
3
+ current_app_version text,
4
+ current_bundle_id uuid
5
+ )
6
+ RETURNS TABLE (
7
+ id uuid,
8
+ force_update boolean,
9
+ file_url text,
10
+ file_hash text,
11
+ status text
12
+ )
13
+ LANGUAGE plpgsql
14
+ AS
15
+ $$
16
+ DECLARE
17
+ NIL_UUID CONSTANT uuid := '00000000-0000-0000-0000-000000000000';
18
+ BEGIN
19
+ RETURN QUERY
20
+ WITH rollback_candidate AS (
21
+ SELECT
22
+ b.id,
23
+ -- If status is 'ROLLBACK', force_update is always TRUE
24
+ TRUE AS force_update,
25
+ b.file_url,
26
+ b.file_hash,
27
+ 'ROLLBACK' AS status
28
+ FROM bundles b
29
+ WHERE b.enabled = TRUE
30
+ AND b.platform = current_platform
31
+ AND b.id < current_bundle_id
32
+ ORDER BY b.id DESC
33
+ LIMIT 1
34
+ ),
35
+ update_candidate AS (
36
+ SELECT
37
+ b.id,
38
+ b.force_update,
39
+ b.file_url,
40
+ b.file_hash,
41
+ 'UPDATE' AS status
42
+ FROM bundles b
43
+ WHERE b.enabled = TRUE
44
+ AND b.platform = current_platform
45
+ AND b.id >= current_bundle_id
46
+ AND semver_satisfies(b.target_version, current_app_version)
47
+ ORDER BY b.id DESC
48
+ LIMIT 1
49
+ ),
50
+ final_result AS (
51
+ SELECT *
52
+ FROM update_candidate
53
+
54
+ UNION ALL
55
+
56
+ SELECT *
57
+ FROM rollback_candidate
58
+ WHERE NOT EXISTS (SELECT 1 FROM update_candidate)
59
+ )
60
+ SELECT *
61
+ FROM final_result WHERE final_result.id != current_bundle_id
62
+
63
+ UNION ALL
64
+ /*
65
+ When there are no final results and current_bundle_id != NIL_UUID,
66
+ add one fallback row.
67
+ This fallback row is also ROLLBACK so forceUpdate = TRUE.
68
+ */
69
+ SELECT
70
+ NIL_UUID AS id,
71
+ TRUE AS force_update, -- Always TRUE
72
+ NULL AS file_url,
73
+ NULL AS file_hash,
74
+ 'ROLLBACK' AS status
75
+ WHERE (SELECT COUNT(*) FROM final_result) = 0
76
+ AND current_bundle_id != NIL_UUID;
77
+
78
+ END;
79
+ $$;
@@ -0,0 +1,74 @@
1
+ import { PGlite } from "@electric-sql/pglite";
2
+ import type { Bundle, GetBundlesArgs, UpdateInfo } from "@hot-updater/core";
3
+ import { setupGetUpdateInfoTestSuite } from "@hot-updater/core/test-utils";
4
+ import camelcaseKeys from "camelcase-keys";
5
+ import { afterAll, beforeEach, describe } from "vitest";
6
+ import { prepareSql } from "./prepareSql";
7
+
8
+ const createInsertBundleQuery = (bundle: Bundle) => {
9
+ return `
10
+ INSERT INTO bundles (
11
+ id, file_url, file_hash, platform, target_version,
12
+ force_update, enabled, git_commit_hash, message
13
+ ) VALUES (
14
+ '${bundle.id}',
15
+ '${bundle.fileUrl}',
16
+ '${bundle.fileHash}',
17
+ '${bundle.platform}',
18
+ '${bundle.targetVersion}',
19
+ ${bundle.forceUpdate},
20
+ ${bundle.enabled},
21
+ ${bundle.gitCommitHash ? `'${bundle.gitCommitHash}'` : "null"},
22
+ ${bundle.message ? `'${bundle.message}'` : "null"}
23
+ );
24
+ `;
25
+ };
26
+
27
+ const createGetUpdateInfo =
28
+ (db: PGlite) =>
29
+ async (
30
+ bundles: Bundle[],
31
+ { appVersion, bundleId, platform }: GetBundlesArgs,
32
+ ): Promise<UpdateInfo | null> => {
33
+ await db.exec(createInsertBundleQuerys(bundles));
34
+
35
+ const result = await db.query<{
36
+ id: string;
37
+ force_update: boolean;
38
+ file_url: string;
39
+ file_hash: string;
40
+ status: string;
41
+ }>(
42
+ `
43
+ SELECT * FROM get_update_info('${platform}', '${appVersion}', '${bundleId}')
44
+ `,
45
+ );
46
+
47
+ return result.rows[0]
48
+ ? (camelcaseKeys(result.rows[0]) as UpdateInfo)
49
+ : null;
50
+ };
51
+
52
+ const createInsertBundleQuerys = (bundles: Bundle[]) => {
53
+ return bundles.map(createInsertBundleQuery).join("\n");
54
+ };
55
+
56
+ const db = new PGlite();
57
+
58
+ const sql = await prepareSql();
59
+ await db.exec(sql);
60
+ const getUpdateInfo = createGetUpdateInfo(db);
61
+
62
+ describe("getUpdateInfo", () => {
63
+ beforeEach(async () => {
64
+ await db.exec("DELETE FROM bundles");
65
+ });
66
+
67
+ afterAll(async () => {
68
+ await db.close();
69
+ });
70
+
71
+ setupGetUpdateInfoTestSuite({
72
+ getUpdateInfo: getUpdateInfo,
73
+ });
74
+ });
@@ -0,0 +1,11 @@
1
+ import path from "path";
2
+ import fs from "fs/promises";
3
+
4
+ export const prepareSql = async () => {
5
+ const files = await fs.readdir(__dirname);
6
+ const sqlFiles = files.filter((file) => file.endsWith(".sql"));
7
+ const contents = await Promise.all(
8
+ sqlFiles.map((file) => fs.readFile(path.join(__dirname, file), "utf-8")),
9
+ );
10
+ return contents.join("\n");
11
+ };
package/sql/semver.sql ADDED
@@ -0,0 +1,97 @@
1
+ CREATE OR REPLACE FUNCTION semver_satisfies(range_expression TEXT, version TEXT)
2
+ RETURNS BOOLEAN AS $$
3
+ DECLARE
4
+ version_parts TEXT[];
5
+ version_major INT;
6
+ version_minor INT;
7
+ version_patch INT;
8
+ satisfies BOOLEAN := FALSE;
9
+ BEGIN
10
+ -- Split the version into major, minor, and patch
11
+ version_parts := string_to_array(version, '.');
12
+ version_major := version_parts[1]::INT;
13
+ version_minor := version_parts[2]::INT;
14
+ version_patch := version_parts[3]::INT;
15
+
16
+ -- Parse range expression and evaluate
17
+ IF range_expression ~ '^\d+\.\d+\.\d+$' THEN
18
+ -- Exact match
19
+ satisfies := (range_expression = version);
20
+
21
+ ELSIF range_expression = '*' THEN
22
+ -- Matches any version
23
+ satisfies := TRUE;
24
+
25
+ ELSIF range_expression ~ '^\d+\.x\.x$' THEN
26
+ -- Matches major.x.x
27
+ DECLARE
28
+ major_range INT := split_part(range_expression, '.', 1)::INT;
29
+ BEGIN
30
+ satisfies := (version_major = major_range);
31
+ END;
32
+
33
+ ELSIF range_expression ~ '^\d+\.\d+\.x$' THEN
34
+ -- Matches major.minor.x
35
+ DECLARE
36
+ major_range INT := split_part(range_expression, '.', 1)::INT;
37
+ minor_range INT := split_part(range_expression, '.', 2)::INT;
38
+ BEGIN
39
+ satisfies := (version_major = major_range AND version_minor = minor_range);
40
+ END;
41
+
42
+ ELSIF range_expression ~ '^\d+\.\d+$' THEN
43
+ -- Matches major.minor
44
+ DECLARE
45
+ major_range INT := split_part(range_expression, '.', 1)::INT;
46
+ minor_range INT := split_part(range_expression, '.', 2)::INT;
47
+ BEGIN
48
+ satisfies := (version_major = major_range AND version_minor = minor_range);
49
+ END;
50
+
51
+ ELSIF range_expression ~ '^\d+\.\d+\.\d+ - \d+\.\d+\.\d+$' THEN
52
+ -- Matches range e.g., 1.2.3 - 1.2.7
53
+ DECLARE
54
+ lower_bound TEXT := split_part(range_expression, ' - ', 1);
55
+ upper_bound TEXT := split_part(range_expression, ' - ', 2);
56
+ BEGIN
57
+ satisfies := (version >= lower_bound AND version <= upper_bound);
58
+ END;
59
+
60
+ ELSIF range_expression ~ '^>=\d+\.\d+\.\d+ <\d+\.\d+\.\d+$' THEN
61
+ -- Matches range with inequalities
62
+ DECLARE
63
+ lower_bound TEXT := regexp_replace(range_expression, '>=([\d\.]+) <.*', '\1');
64
+ upper_bound TEXT := regexp_replace(range_expression, '.*<([\d\.]+)', '\1');
65
+ BEGIN
66
+ satisfies := (version >= lower_bound AND version < upper_bound);
67
+ END;
68
+
69
+ ELSIF range_expression ~ '^~\d+\.\d+\.\d+$' THEN
70
+ -- Matches ~1.2.3 (>=1.2.3 <1.3.0)
71
+ DECLARE
72
+ lower_bound TEXT := regexp_replace(range_expression, '~', '');
73
+ upper_bound_major INT := split_part(lower_bound, '.', 1)::INT;
74
+ upper_bound_minor INT := split_part(lower_bound, '.', 2)::INT + 1;
75
+ upper_bound TEXT := upper_bound_major || '.' || upper_bound_minor || '.0';
76
+ BEGIN
77
+ satisfies := (version >= lower_bound AND version < upper_bound);
78
+ END;
79
+
80
+ ELSIF range_expression ~ '^\^\d+\.\d+\.\d+$' THEN
81
+ -- Matches ^1.2.3 (>=1.2.3 <2.0.0)
82
+ DECLARE
83
+ lower_bound TEXT := regexp_replace(range_expression, '\^', '');
84
+ upper_bound_major INT := split_part(lower_bound, '.', 1)::INT + 1;
85
+ upper_bound TEXT := upper_bound_major || '.0.0';
86
+ BEGIN
87
+ satisfies := (version >= lower_bound AND version < upper_bound);
88
+ END;
89
+
90
+ ELSE
91
+ RAISE EXCEPTION 'Unsupported range expression: %', range_expression;
92
+ END IF;
93
+
94
+ RETURN satisfies;
95
+ END;
96
+ $$ LANGUAGE plpgsql;
97
+
@@ -0,0 +1,26 @@
1
+ import { PGlite } from "@electric-sql/pglite";
2
+ import { setupSemverSatisfiesTestSuite } from "@hot-updater/core/test-utils";
3
+ import { afterAll, describe } from "vitest";
4
+ import { prepareSql } from "./prepareSql";
5
+
6
+ const db = new PGlite();
7
+ const sql = await prepareSql();
8
+ await db.exec(sql);
9
+
10
+ const createSemverSatisfies =
11
+ (db: PGlite) => async (targetVersion: string, currentVersion: string) => {
12
+ const result = await db.query<{ actual: boolean }>(`
13
+ SELECT semver_satisfies('${targetVersion}', '${currentVersion}') AS actual;
14
+ `);
15
+ return result.rows[0].actual;
16
+ };
17
+
18
+ const semverSatisfies = createSemverSatisfies(db);
19
+
20
+ describe("semverSatisfies", () => {
21
+ afterAll(async () => {
22
+ await db.close();
23
+ });
24
+
25
+ setupSemverSatisfiesTestSuite({ semverSatisfies });
26
+ });