@hot-updater/postgres 0.27.1 → 0.29.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/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@hot-updater/postgres",
3
3
  "type": "module",
4
- "version": "0.27.1",
4
+ "version": "0.29.0",
5
5
  "description": "React Native OTA solution for self-hosted",
6
6
  "main": "./dist/index.cjs",
7
- "module": "./dist/index.js",
7
+ "module": "./dist/index.mjs",
8
8
  "exports": {
9
9
  ".": {
10
- "import": "./dist/index.js",
10
+ "import": "./dist/index.mjs",
11
11
  "require": "./dist/index.cjs"
12
12
  },
13
13
  "./package.json": "./package.json"
@@ -31,16 +31,23 @@
31
31
  "dependencies": {
32
32
  "kysely": "^0.28.5",
33
33
  "pg": "^8.13.1",
34
- "@hot-updater/core": "0.27.1",
35
- "@hot-updater/plugin-core": "0.27.1"
34
+ "@hot-updater/core": "0.29.0",
35
+ "@hot-updater/plugin-core": "0.29.0"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@electric-sql/pglite": "^0.2.15",
39
39
  "@types/pg": "^8.11.10",
40
40
  "camelcase-keys": "^9.1.3",
41
41
  "pg-minify": "^1.6.5",
42
- "@hot-updater/js": "0.27.1",
43
- "@hot-updater/test-utils": "0.27.1"
42
+ "@hot-updater/js": "0.29.0",
43
+ "@hot-updater/test-utils": "0.29.0"
44
+ },
45
+ "inlinedDependencies": {
46
+ "camelcase": "8.0.0",
47
+ "camelcase-keys": "9.1.3",
48
+ "map-obj": "5.0.0",
49
+ "pg-minify": "1.6.5",
50
+ "quick-lru": "6.1.2"
44
51
  },
45
52
  "scripts": {
46
53
  "build": "tsdown",
package/sql/bundles.sql CHANGED
@@ -17,9 +17,14 @@ CREATE TABLE bundles (
17
17
  CONSTRAINT check_version_or_fingerprint CHECK (
18
18
  (target_app_version IS NOT NULL) OR (fingerprint_hash IS NOT NULL)
19
19
  ),
20
- metadata jsonb DEFAULT '{}'::jsonb
20
+ metadata jsonb DEFAULT '{}'::jsonb,
21
+ rollout_cohort_count INTEGER DEFAULT 1000
22
+ CHECK (rollout_cohort_count >= 0 AND rollout_cohort_count <= 1000),
23
+ target_cohorts TEXT[]
21
24
  );
22
25
 
23
26
  CREATE INDEX bundles_target_app_version_idx ON bundles(target_app_version);
24
27
  CREATE INDEX bundles_fingerprint_hash_idx ON bundles(fingerprint_hash);
25
- CREATE INDEX bundles_channel_idx ON bundles(channel);
28
+ CREATE INDEX bundles_channel_idx ON bundles(channel);
29
+ CREATE INDEX bundles_rollout_idx ON bundles(rollout_cohort_count);
30
+ CREATE INDEX bundles_target_cohorts_idx ON bundles USING GIN (target_cohorts);
@@ -12,23 +12,43 @@ import { afterAll, beforeEach, describe } from "vitest";
12
12
  import { prepareSql } from "./prepareSql";
13
13
 
14
14
  const createInsertBundleQuery = (bundle: Bundle) => {
15
+ const rolloutCohortCount = bundle.rolloutCohortCount ?? 1000;
16
+ const targetCohorts = bundle.targetCohorts
17
+ ? `ARRAY[${bundle.targetCohorts.map((id) => `'${id}'`).join(",")}]::TEXT[]`
18
+ : "NULL";
19
+
15
20
  return `
16
21
  INSERT INTO bundles (
17
22
  id, file_hash, platform, target_app_version,
18
- should_force_update, enabled, git_commit_hash, message, channel, storage_uri, fingerprint_hash
23
+ should_force_update, enabled, git_commit_hash, message, channel, storage_uri, fingerprint_hash,
24
+ rollout_cohort_count, target_cohorts
19
25
  ) VALUES (
20
26
  '${bundle.id}',
21
27
  '${bundle.fileHash}',
22
28
  '${bundle.platform}',
23
- '${bundle.targetAppVersion}',
29
+ ${bundle.targetAppVersion ? `'${bundle.targetAppVersion}'` : "null"},
24
30
  ${bundle.shouldForceUpdate},
25
31
  ${bundle.enabled},
26
32
  ${bundle.gitCommitHash ? `'${bundle.gitCommitHash}'` : "null"},
27
33
  ${bundle.message ? `'${bundle.message}'` : "null"},
28
34
  '${bundle.channel}',
29
35
  '${bundle.storageUri}',
30
- '${bundle.fingerprintHash}'
31
- );
36
+ ${bundle.fingerprintHash ? `'${bundle.fingerprintHash}'` : "null"},
37
+ ${rolloutCohortCount},
38
+ ${targetCohorts}
39
+ ) ON CONFLICT (id) DO UPDATE SET
40
+ file_hash = EXCLUDED.file_hash,
41
+ platform = EXCLUDED.platform,
42
+ target_app_version = EXCLUDED.target_app_version,
43
+ should_force_update = EXCLUDED.should_force_update,
44
+ enabled = EXCLUDED.enabled,
45
+ git_commit_hash = EXCLUDED.git_commit_hash,
46
+ message = EXCLUDED.message,
47
+ channel = EXCLUDED.channel,
48
+ storage_uri = EXCLUDED.storage_uri,
49
+ fingerprint_hash = EXCLUDED.fingerprint_hash,
50
+ rollout_cohort_count = EXCLUDED.rollout_cohort_count,
51
+ target_cohorts = EXCLUDED.target_cohorts;
32
52
  `;
33
53
  };
34
54
 
@@ -49,11 +69,15 @@ const createGetUpdateInfo =
49
69
 
50
70
  if (_updateStrategy === "fingerprint") {
51
71
  const fingerprintHash = args.fingerprintHash;
72
+ const cohort = args.cohort;
73
+ const cohortSql = cohort ? `'${cohort}'` : "NULL";
52
74
  const result = await db.query<{
53
75
  id: string;
54
76
  should_force_update: boolean;
55
77
  message: string;
56
78
  status: string;
79
+ storage_uri: string | null;
80
+ file_hash: string | null;
57
81
  }>(
58
82
  `
59
83
  SELECT * FROM get_update_info_by_fingerprint_hash(
@@ -61,14 +85,18 @@ const createGetUpdateInfo =
61
85
  '${bundleId}',
62
86
  '${minBundleId}',
63
87
  '${channel}',
64
- '${fingerprintHash}'
88
+ '${fingerprintHash}',
89
+ ${cohortSql}
65
90
  );
66
91
  `,
67
92
  );
68
93
 
69
- return result.rows[0]
70
- ? (camelcaseKeys(result.rows[0]) as UpdateInfo)
71
- : null;
94
+ if (!result.rows[0]) {
95
+ return null;
96
+ }
97
+
98
+ const row = result.rows[0];
99
+ return camelcaseKeys(row) as UpdateInfo;
72
100
  }
73
101
 
74
102
  const appVersion = args.appVersion;
@@ -85,11 +113,15 @@ const createGetUpdateInfo =
85
113
  appVersion,
86
114
  );
87
115
 
116
+ const cohort = args.cohort;
117
+ const cohortSql = cohort ? `'${cohort}'` : "NULL";
88
118
  const result = await db.query<{
89
119
  id: string;
90
120
  should_force_update: boolean;
91
121
  message: string;
92
122
  status: string;
123
+ storage_uri: string | null;
124
+ file_hash: string | null;
93
125
  }>(
94
126
  `
95
127
  SELECT * FROM get_update_info_by_app_version(
@@ -98,14 +130,18 @@ const createGetUpdateInfo =
98
130
  '${bundleId}',
99
131
  '${minBundleId ?? NIL_UUID}',
100
132
  '${channel}',
101
- ARRAY[${targetAppVersionList.map((v) => `'${v}'`).join(",")}]::text[]
133
+ ARRAY[${targetAppVersionList.map((v) => `'${v}'`).join(",")}]::text[],
134
+ ${cohortSql}
102
135
  );
103
136
  `,
104
137
  );
105
138
 
106
- return result.rows[0]
107
- ? (camelcaseKeys(result.rows[0]) as UpdateInfo)
108
- : null;
139
+ if (!result.rows[0]) {
140
+ return null;
141
+ }
142
+
143
+ const row = result.rows[0];
144
+ return camelcaseKeys(row) as UpdateInfo;
109
145
  };
110
146
 
111
147
  const createInsertBundleQuerys = (bundles: Bundle[]) => {
@@ -1,4 +1,6 @@
1
- -- HotUpdater.get_update_info
1
+ -- HotUpdater.get_update_info_by_app_version
2
+
3
+ DROP FUNCTION IF EXISTS get_update_info_by_app_version;
2
4
 
3
5
  CREATE OR REPLACE FUNCTION get_update_info_by_app_version (
4
6
  app_platform platforms,
@@ -6,7 +8,8 @@ CREATE OR REPLACE FUNCTION get_update_info_by_app_version (
6
8
  bundle_id uuid,
7
9
  min_bundle_id uuid,
8
10
  target_channel text,
9
- target_app_version_list text[]
11
+ target_app_version_list text[],
12
+ cohort TEXT DEFAULT NULL
10
13
  )
11
14
  RETURNS TABLE (
12
15
  id uuid,
@@ -23,45 +26,78 @@ DECLARE
23
26
  NIL_UUID CONSTANT uuid := '00000000-0000-0000-0000-000000000000';
24
27
  BEGIN
25
28
  RETURN QUERY
26
- WITH update_candidate AS (
29
+ WITH candidate_bundles AS (
27
30
  SELECT
28
31
  b.id,
29
32
  b.should_force_update,
30
33
  b.message,
31
- 'UPDATE' AS status,
32
34
  b.storage_uri,
33
- b.file_hash
35
+ b.file_hash,
36
+ b.rollout_cohort_count,
37
+ b.target_cohorts
34
38
  FROM bundles b
35
39
  WHERE b.enabled = TRUE
36
40
  AND b.platform = app_platform
37
- AND b.id >= bundle_id
38
- AND b.id > min_bundle_id
41
+ AND b.id >= min_bundle_id
39
42
  AND b.target_app_version IN (SELECT unnest(target_app_version_list))
40
43
  AND b.channel = target_channel
41
- ORDER BY b.id DESC
44
+ ),
45
+ current_candidate AS (
46
+ SELECT
47
+ cb.id,
48
+ is_cohort_eligible(
49
+ cb.id,
50
+ cohort,
51
+ cb.rollout_cohort_count,
52
+ cb.target_cohorts
53
+ ) AS is_eligible
54
+ FROM candidate_bundles cb
55
+ WHERE cb.id = bundle_id
56
+ LIMIT 1
57
+ ),
58
+ eligible_update_candidate AS (
59
+ SELECT
60
+ cb.id,
61
+ cb.should_force_update,
62
+ cb.message,
63
+ 'UPDATE' AS status,
64
+ cb.storage_uri,
65
+ cb.file_hash
66
+ FROM candidate_bundles cb
67
+ WHERE cb.id > bundle_id
68
+ AND is_cohort_eligible(
69
+ cb.id,
70
+ cohort,
71
+ cb.rollout_cohort_count,
72
+ cb.target_cohorts
73
+ )
74
+ ORDER BY cb.id DESC
42
75
  LIMIT 1
43
76
  ),
44
77
  rollback_candidate AS (
45
78
  SELECT
46
- b.id,
79
+ cb.id,
47
80
  TRUE AS should_force_update,
48
- b.message,
81
+ cb.message,
49
82
  'ROLLBACK' AS status,
50
- b.storage_uri,
51
- b.file_hash
52
- FROM bundles b
53
- WHERE b.enabled = TRUE
54
- AND b.platform = app_platform
55
- AND b.id < bundle_id
56
- AND b.id > min_bundle_id
57
- ORDER BY b.id DESC
83
+ cb.storage_uri,
84
+ cb.file_hash
85
+ FROM candidate_bundles cb
86
+ WHERE cb.id < bundle_id
87
+ AND NOT EXISTS (
88
+ SELECT 1
89
+ FROM current_candidate
90
+ WHERE current_candidate.is_eligible = TRUE
91
+ )
92
+ AND NOT EXISTS (SELECT 1 FROM eligible_update_candidate)
93
+ ORDER BY cb.id DESC
58
94
  LIMIT 1
59
95
  ),
60
96
  final_result AS (
61
- SELECT * FROM update_candidate
97
+ SELECT * FROM eligible_update_candidate
62
98
  UNION ALL
63
99
  SELECT * FROM rollback_candidate
64
- WHERE NOT EXISTS (SELECT 1 FROM update_candidate)
100
+ WHERE NOT EXISTS (SELECT 1 FROM eligible_update_candidate)
65
101
  )
66
102
  SELECT *
67
103
  FROM final_result
@@ -81,10 +117,10 @@ BEGIN
81
117
  AND bundle_id > min_bundle_id
82
118
  AND NOT EXISTS (
83
119
  SELECT 1
84
- FROM bundles b
85
- WHERE b.id = bundle_id
86
- AND b.enabled = TRUE
87
- AND b.platform = app_platform
88
- );
120
+ FROM current_candidate
121
+ WHERE current_candidate.is_eligible = TRUE
122
+ )
123
+ AND NOT EXISTS (SELECT 1 FROM eligible_update_candidate)
124
+ AND NOT EXISTS (SELECT 1 FROM rollback_candidate);
89
125
  END;
90
126
  $$;
@@ -1,11 +1,14 @@
1
- -- HotUpdater.get_update_info
1
+ -- HotUpdater.get_update_info_by_fingerprint_hash
2
+
3
+ DROP FUNCTION IF EXISTS get_update_info_by_fingerprint_hash;
2
4
 
3
5
  CREATE OR REPLACE FUNCTION get_update_info_by_fingerprint_hash (
4
6
  app_platform platforms,
5
7
  bundle_id uuid,
6
8
  min_bundle_id uuid,
7
9
  target_channel text,
8
- target_fingerprint_hash text
10
+ target_fingerprint_hash text,
11
+ cohort TEXT DEFAULT NULL
9
12
  )
10
13
  RETURNS TABLE (
11
14
  id uuid,
@@ -22,47 +25,78 @@ DECLARE
22
25
  NIL_UUID CONSTANT uuid := '00000000-0000-0000-0000-000000000000';
23
26
  BEGIN
24
27
  RETURN QUERY
25
- WITH update_candidate AS (
28
+ WITH candidate_bundles AS (
26
29
  SELECT
27
30
  b.id,
28
31
  b.should_force_update,
29
32
  b.message,
30
- 'UPDATE' AS status,
31
33
  b.storage_uri,
32
- b.file_hash
34
+ b.file_hash,
35
+ b.rollout_cohort_count,
36
+ b.target_cohorts
33
37
  FROM bundles b
34
38
  WHERE b.enabled = TRUE
35
39
  AND b.platform = app_platform
36
- AND b.id >= bundle_id
37
- AND b.id > min_bundle_id
40
+ AND b.id >= min_bundle_id
38
41
  AND b.channel = target_channel
39
42
  AND b.fingerprint_hash = target_fingerprint_hash
40
- ORDER BY b.id DESC
43
+ ),
44
+ current_candidate AS (
45
+ SELECT
46
+ cb.id,
47
+ is_cohort_eligible(
48
+ cb.id,
49
+ cohort,
50
+ cb.rollout_cohort_count,
51
+ cb.target_cohorts
52
+ ) AS is_eligible
53
+ FROM candidate_bundles cb
54
+ WHERE cb.id = bundle_id
55
+ LIMIT 1
56
+ ),
57
+ eligible_update_candidate AS (
58
+ SELECT
59
+ cb.id,
60
+ cb.should_force_update,
61
+ cb.message,
62
+ 'UPDATE' AS status,
63
+ cb.storage_uri,
64
+ cb.file_hash
65
+ FROM candidate_bundles cb
66
+ WHERE cb.id > bundle_id
67
+ AND is_cohort_eligible(
68
+ cb.id,
69
+ cohort,
70
+ cb.rollout_cohort_count,
71
+ cb.target_cohorts
72
+ )
73
+ ORDER BY cb.id DESC
41
74
  LIMIT 1
42
75
  ),
43
76
  rollback_candidate AS (
44
77
  SELECT
45
- b.id,
78
+ cb.id,
46
79
  TRUE AS should_force_update,
47
- b.message,
80
+ cb.message,
48
81
  'ROLLBACK' AS status,
49
- b.storage_uri,
50
- b.file_hash
51
- FROM bundles b
52
- WHERE b.enabled = TRUE
53
- AND b.platform = app_platform
54
- AND b.id < bundle_id
55
- AND b.id > min_bundle_id
56
- AND b.channel = target_channel
57
- AND b.fingerprint_hash = target_fingerprint_hash
58
- ORDER BY b.id DESC
82
+ cb.storage_uri,
83
+ cb.file_hash
84
+ FROM candidate_bundles cb
85
+ WHERE cb.id < bundle_id
86
+ AND NOT EXISTS (
87
+ SELECT 1
88
+ FROM current_candidate
89
+ WHERE current_candidate.is_eligible = TRUE
90
+ )
91
+ AND NOT EXISTS (SELECT 1 FROM eligible_update_candidate)
92
+ ORDER BY cb.id DESC
59
93
  LIMIT 1
60
94
  ),
61
95
  final_result AS (
62
- SELECT * FROM update_candidate
96
+ SELECT * FROM eligible_update_candidate
63
97
  UNION ALL
64
98
  SELECT * FROM rollback_candidate
65
- WHERE NOT EXISTS (SELECT 1 FROM update_candidate)
99
+ WHERE NOT EXISTS (SELECT 1 FROM eligible_update_candidate)
66
100
  )
67
101
  SELECT *
68
102
  FROM final_result
@@ -82,10 +116,10 @@ BEGIN
82
116
  AND bundle_id > min_bundle_id
83
117
  AND NOT EXISTS (
84
118
  SELECT 1
85
- FROM bundles b
86
- WHERE b.id = bundle_id
87
- AND b.enabled = TRUE
88
- AND b.platform = app_platform
89
- );
119
+ FROM current_candidate
120
+ WHERE current_candidate.is_eligible = TRUE
121
+ )
122
+ AND NOT EXISTS (SELECT 1 FROM eligible_update_candidate)
123
+ AND NOT EXISTS (SELECT 1 FROM rollback_candidate);
90
124
  END;
91
125
  $$;
@@ -0,0 +1,30 @@
1
+ -- Deterministic hash function matching JavaScript implementation
2
+ -- Returns hash value in range [0, 99]
3
+ CREATE OR REPLACE FUNCTION hash_user_id(user_id TEXT)
4
+ RETURNS INTEGER
5
+ LANGUAGE plpgsql
6
+ IMMUTABLE
7
+ AS $$
8
+ DECLARE
9
+ hash BIGINT := 0;
10
+ char_code INTEGER;
11
+ i INTEGER;
12
+ normalized BIGINT;
13
+ BEGIN
14
+ -- Replicate JavaScript hash algorithm
15
+ FOR i IN 1..length(user_id) LOOP
16
+ char_code := ascii(substring(user_id from i for 1));
17
+ hash := (hash * 31) + char_code;
18
+
19
+ -- Simulate JavaScript's |= 0 by wrapping into a signed 32-bit range.
20
+ normalized := mod(hash + 2147483648, 4294967296);
21
+ IF normalized < 0 THEN
22
+ normalized := normalized + 4294967296;
23
+ END IF;
24
+ hash := normalized - 2147483648;
25
+ END LOOP;
26
+
27
+ -- Return absolute value modulo 100
28
+ RETURN abs(hash % 100);
29
+ END;
30
+ $$;