@saws/powersync-service 2.0.0-beta.3
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 +21 -0
- package/src/PowerSyncService.ts +296 -0
- package/src/index.ts +1 -0
- package/src/powersync-command.ts +57 -0
- package/tsconfig.json +13 -0
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@saws/powersync-service",
|
|
3
|
+
"version": "2.0.0-beta.3",
|
|
4
|
+
"description": "",
|
|
5
|
+
"license": "ISC",
|
|
6
|
+
"author": "",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@saws/core": "2.0.0-beta.3",
|
|
17
|
+
"@saws/docker-service": "2.0.0-beta.3",
|
|
18
|
+
"@saws/postgres-service": "2.0.0-beta.3",
|
|
19
|
+
"commander": "^15.0.0"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { ServiceDefinition } from "@saws/core";
|
|
4
|
+
import { hasDependency, installDependencies } from "@saws/core/utils/dependency-management";
|
|
5
|
+
import { fileExists } from "@saws/core/utils/file-exists";
|
|
6
|
+
import {
|
|
7
|
+
DockerService,
|
|
8
|
+
type DockerRunConfig,
|
|
9
|
+
type DockerServiceConfig,
|
|
10
|
+
} from "@saws/docker-service";
|
|
11
|
+
import type { PostgresService } from "@saws/postgres-service";
|
|
12
|
+
import { createPowerSyncCommand, runPowerSyncCli } from "./powersync-command.js";
|
|
13
|
+
|
|
14
|
+
const POWERSYNC_CONFIG_PATH = "/config/service.yaml";
|
|
15
|
+
const POWERSYNC_SYNC_CONFIG_PATH = "/config/sync-config.yaml";
|
|
16
|
+
const POWERSYNC_DIRECTORY = "powersync";
|
|
17
|
+
|
|
18
|
+
export interface PowerSyncServiceConfig extends Omit<
|
|
19
|
+
DockerServiceConfig,
|
|
20
|
+
"image" | "dockerfile" | "buildContext" | "volumes" | "ports" | "command"
|
|
21
|
+
> {
|
|
22
|
+
image?: string;
|
|
23
|
+
/** Application database replicated by PowerSync. */
|
|
24
|
+
applicationDatabase: PostgresService;
|
|
25
|
+
/** PowerSync storage database. */
|
|
26
|
+
powersyncDatabase: PostgresService;
|
|
27
|
+
/** Host port to expose PowerSync on. Defaults to PS_PORT or 8080. */
|
|
28
|
+
port?: number;
|
|
29
|
+
/** Node heap size passed through NODE_OPTIONS. */
|
|
30
|
+
maxOldSpaceSize?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class PowerSyncService extends DockerService {
|
|
34
|
+
static getCommands(services: ServiceDefinition[] = []) {
|
|
35
|
+
return [createPowerSyncCommand(services.filter(isPowerSyncService))];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
readonly applicationDatabase: PostgresService;
|
|
39
|
+
readonly powersyncDatabase: PostgresService;
|
|
40
|
+
readonly port: number;
|
|
41
|
+
readonly maxOldSpaceSize: number;
|
|
42
|
+
protected override readonly serviceType = "powersync";
|
|
43
|
+
|
|
44
|
+
constructor(config: PowerSyncServiceConfig) {
|
|
45
|
+
super({
|
|
46
|
+
...config,
|
|
47
|
+
image: config.image ?? "journeyapps/powersync-service:latest",
|
|
48
|
+
command: ["start", "-r", "unified"],
|
|
49
|
+
dependencies: [
|
|
50
|
+
...(config.dependencies ?? []),
|
|
51
|
+
config.applicationDatabase,
|
|
52
|
+
config.powersyncDatabase,
|
|
53
|
+
],
|
|
54
|
+
healthCheck: config.healthCheck ?? {
|
|
55
|
+
command:
|
|
56
|
+
"node -e \"fetch('http://localhost:${PS_PORT}/probes/liveness').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))\"",
|
|
57
|
+
interval: "5s",
|
|
58
|
+
timeout: "1s",
|
|
59
|
+
retries: 15,
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
this.applicationDatabase = config.applicationDatabase;
|
|
64
|
+
this.powersyncDatabase = config.powersyncDatabase;
|
|
65
|
+
this.port = config.port ?? Number(process.env["PS_PORT"] ?? 8080);
|
|
66
|
+
this.maxOldSpaceSize = config.maxOldSpaceSize ?? 1000;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
override async init() {
|
|
70
|
+
await super.init();
|
|
71
|
+
await mkdir(path.resolve(this.name), { recursive: true });
|
|
72
|
+
|
|
73
|
+
if (
|
|
74
|
+
!(await hasDependency("powersync")) ||
|
|
75
|
+
!(await fileExists(path.resolve("node_modules", ".bin", "powersync")))
|
|
76
|
+
) {
|
|
77
|
+
await installDependencies(["powersync"], { development: true });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const serviceConfigPath = this.getLocalPowerSyncFilePath("service.yaml");
|
|
81
|
+
if (!(await fileExists(serviceConfigPath))) {
|
|
82
|
+
await this.runPowerSyncCli(["init", "self-hosted"]);
|
|
83
|
+
}
|
|
84
|
+
await this.configureGeneratedServiceConfig(serviceConfigPath);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
override async deploy(stage: string) {
|
|
88
|
+
if (this.host != null) {
|
|
89
|
+
await this.writeRemoteRuntimeFile(
|
|
90
|
+
stage,
|
|
91
|
+
`${this.name}/${POWERSYNC_DIRECTORY}/service.yaml`,
|
|
92
|
+
await readFile(this.getLocalPowerSyncFilePath("service.yaml"), "utf8"),
|
|
93
|
+
);
|
|
94
|
+
await this.writeRemoteRuntimeFile(
|
|
95
|
+
stage,
|
|
96
|
+
`${this.name}/${POWERSYNC_DIRECTORY}/sync-config.yaml`,
|
|
97
|
+
await readFile(this.getLocalPowerSyncFilePath("sync-config.yaml"), "utf8"),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
await super.deploy(stage);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async runPowerSyncCli(powersyncArgs: string[]) {
|
|
105
|
+
await runPowerSyncCli(path.resolve(this.name), powersyncArgs);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
protected override async getContainerEnvironment(stage: string): Promise<Record<string, string>> {
|
|
109
|
+
const applicationDatabase = await this.applicationDatabase.getConnectionInfo(
|
|
110
|
+
stage,
|
|
111
|
+
"container",
|
|
112
|
+
);
|
|
113
|
+
const powersyncDatabase = await this.powersyncDatabase.getConnectionInfo(stage, "container");
|
|
114
|
+
const port = String(this.port);
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
...(await super.getContainerEnvironment(stage)),
|
|
118
|
+
POWERSYNC_CONFIG_PATH,
|
|
119
|
+
NODE_OPTIONS: `--max-old-space-size=${this.maxOldSpaceSize}`,
|
|
120
|
+
PS_PORT: port,
|
|
121
|
+
PS_DATA_SOURCE_URI: applicationDatabase.url,
|
|
122
|
+
PS_DATABASE_PASSWORD: applicationDatabase.password,
|
|
123
|
+
PS_STORAGE_SOURCE_URI: powersyncDatabase.url,
|
|
124
|
+
PS_STORAGE_URI: powersyncDatabase.url,
|
|
125
|
+
PS_STORAGE_DATABASE_URL: powersyncDatabase.url,
|
|
126
|
+
PS_POWERSYNC_DATABASE_URI: powersyncDatabase.url,
|
|
127
|
+
PS_POWERSYNC_DATABASE_PASSWORD: powersyncDatabase.password,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
protected override async getDockerRunConfig(stage: string, deploy: boolean) {
|
|
132
|
+
const config = await super.getDockerRunConfig(stage, deploy);
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
...config,
|
|
136
|
+
volumes: [...this.getConfigVolumes(stage), ...(config.volumes ?? [])],
|
|
137
|
+
ports: [`${this.port}:${this.port}`],
|
|
138
|
+
} satisfies DockerRunConfig;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private getConfigVolumes(stage: string) {
|
|
142
|
+
if (stage === "local" || this.host == null) {
|
|
143
|
+
return [
|
|
144
|
+
`${this.getLocalPowerSyncFilePath("service.yaml")}:${POWERSYNC_CONFIG_PATH}:ro`,
|
|
145
|
+
`${this.getLocalPowerSyncFilePath("sync-config.yaml")}:${POWERSYNC_SYNC_CONFIG_PATH}:ro`,
|
|
146
|
+
];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const serviceDirectory = path.posix.join(
|
|
150
|
+
this.getAppDirectory(stage),
|
|
151
|
+
this.name,
|
|
152
|
+
POWERSYNC_DIRECTORY,
|
|
153
|
+
);
|
|
154
|
+
return [
|
|
155
|
+
`${path.posix.join(serviceDirectory, "service.yaml")}:${POWERSYNC_CONFIG_PATH}:ro`,
|
|
156
|
+
`${path.posix.join(serviceDirectory, "sync-config.yaml")}:${POWERSYNC_SYNC_CONFIG_PATH}:ro`,
|
|
157
|
+
];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private getLocalPowerSyncFilePath(fileName: string) {
|
|
161
|
+
return path.resolve(this.name, POWERSYNC_DIRECTORY, fileName);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private async configureGeneratedServiceConfig(serviceConfigPath: string) {
|
|
165
|
+
const serviceConfig = await readFile(serviceConfigPath, "utf8");
|
|
166
|
+
const configured = configurePowerSyncServiceConfig(serviceConfig);
|
|
167
|
+
if (configured !== serviceConfig) {
|
|
168
|
+
await writeFile(serviceConfigPath, configured);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function isPowerSyncService(service: ServiceDefinition): service is PowerSyncService {
|
|
174
|
+
return service instanceof PowerSyncService;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function configurePowerSyncServiceConfig(contents: string) {
|
|
178
|
+
let configured = replaceTopLevelYamlEntry(contents, "port", ["port: !env PS_PORT"]);
|
|
179
|
+
configured = removeTopLevelYamlBlockEntry(configured, "api", "port");
|
|
180
|
+
|
|
181
|
+
configured = replaceTopLevelYamlBlock(configured, "storage", [
|
|
182
|
+
"storage:",
|
|
183
|
+
" sslmode: disable",
|
|
184
|
+
" type: postgresql",
|
|
185
|
+
" uri: !env PS_STORAGE_SOURCE_URI",
|
|
186
|
+
]);
|
|
187
|
+
|
|
188
|
+
if (!hasActiveTopLevelYamlBlock(configured, "replication")) {
|
|
189
|
+
configured = appendTopLevelYamlBlock(configured, [
|
|
190
|
+
"replication:",
|
|
191
|
+
" connections:",
|
|
192
|
+
" - sslmode: disable",
|
|
193
|
+
" type: postgresql",
|
|
194
|
+
" uri: !env PS_DATA_SOURCE_URI",
|
|
195
|
+
]);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return configured.endsWith("\n") ? configured : `${configured}\n`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function hasActiveTopLevelYamlBlock(contents: string, key: string) {
|
|
202
|
+
const pattern = new RegExp(`^${escapeRegExp(key)}:\\s*(?:#.*)?$`, "m");
|
|
203
|
+
return pattern.test(contents);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function appendTopLevelYamlBlock(contents: string, block: string[]) {
|
|
207
|
+
return `${contents.replace(/\s*$/, "\n\n")}${block.join("\n")}\n`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function replaceTopLevelYamlEntry(contents: string, key: string, replacement: string[]) {
|
|
211
|
+
const lines = contents.split(/\r?\n/);
|
|
212
|
+
const start = lines.findIndex((line) =>
|
|
213
|
+
new RegExp(`^${escapeRegExp(key)}:\\s*(?:.*)?$`).test(line),
|
|
214
|
+
);
|
|
215
|
+
if (start === -1) {
|
|
216
|
+
return [...lines, ...replacement].join("\n");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
let end = start + 1;
|
|
220
|
+
while (end < lines.length) {
|
|
221
|
+
const line = lines[end]!;
|
|
222
|
+
if (/^[^\s#][^:]*:\s*(?:.*)?$/.test(line)) break;
|
|
223
|
+
end += 1;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return [...lines.slice(0, start), ...replacement, ...lines.slice(end)].join("\n");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function removeTopLevelYamlBlockEntry(
|
|
230
|
+
contents: string,
|
|
231
|
+
blockKey: string,
|
|
232
|
+
entryKey: string,
|
|
233
|
+
) {
|
|
234
|
+
const lines = contents.split(/\r?\n/);
|
|
235
|
+
const blockStart = lines.findIndex((line) =>
|
|
236
|
+
new RegExp(`^${escapeRegExp(blockKey)}:\\s*(?:#.*)?$`).test(line),
|
|
237
|
+
);
|
|
238
|
+
if (blockStart === -1) {
|
|
239
|
+
return contents;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
let blockEnd = blockStart + 1;
|
|
243
|
+
while (blockEnd < lines.length) {
|
|
244
|
+
const line = lines[blockEnd]!;
|
|
245
|
+
if (/^[^\s#][^:]*:\s*(?:#.*)?$/.test(line)) break;
|
|
246
|
+
blockEnd += 1;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const entryPattern = new RegExp(`^\\s+${escapeRegExp(entryKey)}:\\s*(?:.*)?$`);
|
|
250
|
+
const entryStart = lines.findIndex(
|
|
251
|
+
(line, index) => index > blockStart && index < blockEnd && entryPattern.test(line),
|
|
252
|
+
);
|
|
253
|
+
if (entryStart === -1) {
|
|
254
|
+
return contents;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
let entryEnd = entryStart + 1;
|
|
258
|
+
while (entryEnd < blockEnd) {
|
|
259
|
+
const line = lines[entryEnd]!;
|
|
260
|
+
if (/^\s{2}\S[^:]*:\s*(?:.*)?$/.test(line)) break;
|
|
261
|
+
entryEnd += 1;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const updated = [...lines.slice(0, entryStart), ...lines.slice(entryEnd)];
|
|
265
|
+
const hasRemainingBlockEntries = updated
|
|
266
|
+
.slice(blockStart + 1, blockEnd - (entryEnd - entryStart))
|
|
267
|
+
.some((line) => line.trim() !== "" && !line.trimStart().startsWith("#"));
|
|
268
|
+
if (hasRemainingBlockEntries) {
|
|
269
|
+
return updated.join("\n");
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return [...updated.slice(0, blockStart), ...updated.slice(blockStart + 1)].join("\n");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function replaceTopLevelYamlBlock(contents: string, key: string, replacement: string[]) {
|
|
276
|
+
const lines = contents.split(/\r?\n/);
|
|
277
|
+
const start = lines.findIndex((line) =>
|
|
278
|
+
new RegExp(`^${escapeRegExp(key)}:\\s*(?:#.*)?$`).test(line),
|
|
279
|
+
);
|
|
280
|
+
if (start === -1) {
|
|
281
|
+
return [...lines, ...replacement].join("\n");
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
let end = start + 1;
|
|
285
|
+
while (end < lines.length) {
|
|
286
|
+
const line = lines[end]!;
|
|
287
|
+
if (/^[^\s#][^:]*:\s*(?:#.*)?$/.test(line)) break;
|
|
288
|
+
end += 1;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return [...lines.slice(0, start), ...replacement, ...lines.slice(end)].join("\n");
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function escapeRegExp(value: string) {
|
|
295
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
296
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./PowerSyncService.js";
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import type { PowerSyncService } from "./PowerSyncService.js";
|
|
5
|
+
|
|
6
|
+
export const createPowerSyncCommand = (services: PowerSyncService[]) =>
|
|
7
|
+
new Command("powersync")
|
|
8
|
+
.description("run the PowerSync CLI from the PowerSync service directory")
|
|
9
|
+
.argument("[powersyncArgs...]", "arguments passed to powersync")
|
|
10
|
+
.allowUnknownOption()
|
|
11
|
+
.allowExcessArguments()
|
|
12
|
+
.helpOption(false)
|
|
13
|
+
.action((powersyncArgs: string[]) => powersyncCommand(services, powersyncArgs));
|
|
14
|
+
|
|
15
|
+
async function powersyncCommand(services: PowerSyncService[], powersyncArgs: string[]) {
|
|
16
|
+
const service = resolveService(services);
|
|
17
|
+
await service.runPowerSyncCli(powersyncArgs);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function resolveService(services: PowerSyncService[]) {
|
|
21
|
+
if (services.length === 1) return services[0]!;
|
|
22
|
+
if (services.length === 0) {
|
|
23
|
+
throw new Error("No PowerSync services are configured.");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
throw new Error(
|
|
27
|
+
`Multiple PowerSync services are configured. Use the PowerSync CLI directly from one of these directories: ${services
|
|
28
|
+
.map((service) => path.resolve(service.name))
|
|
29
|
+
.join(", ")}`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function runPowerSyncCli(cwd: string, powersyncArgs: string[]) {
|
|
34
|
+
const powersyncBin = path.resolve("node_modules", ".bin", "powersync");
|
|
35
|
+
|
|
36
|
+
await new Promise<void>((resolve, reject) => {
|
|
37
|
+
const child = spawn(powersyncBin, powersyncArgs, {
|
|
38
|
+
cwd,
|
|
39
|
+
env: process.env,
|
|
40
|
+
stdio: "inherit",
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
child.once("error", (error) => {
|
|
44
|
+
reject(new Error(`Unable to run powersync: ${error.message}`, { cause: error }));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
child.once("close", (code, signal) => {
|
|
48
|
+
if (code === 0) {
|
|
49
|
+
resolve();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const reason = signal == null ? `exit code ${code ?? "unknown"}` : `signal ${signal}`;
|
|
54
|
+
reject(new Error(`powersync failed with ${reason}`));
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig-node.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"outDir": "./dist",
|
|
5
|
+
"rootDir": "./src",
|
|
6
|
+
"tsBuildInfoFile": "./dist/.tsbuildinfo"
|
|
7
|
+
},
|
|
8
|
+
"references": [
|
|
9
|
+
{ "path": "../core/tsconfig.json" },
|
|
10
|
+
{ "path": "../docker-service/tsconfig.json" },
|
|
11
|
+
{ "path": "../postgres-service/tsconfig.json" }
|
|
12
|
+
]
|
|
13
|
+
}
|