create-planet 1.0.2 → 1.0.5
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/README.md +2 -3
- package/index.js +21 -45
- package/package.json +6 -1
- package/template/.github/workflows/publish.yml +6 -4
- package/template/AGENTS.md +8 -8
- package/template/PROTOCOL_DIAGRAMS.md +39 -40
- package/template/package.json +2 -2
- package/template/scripts/simulate-universe.js +0 -10
- package/template/src/env.d.ts +0 -2
- package/template/src/lib/config.ts +8 -2
- package/template/src/lib/consensus.ts +14 -8
- package/template/src/lib/do-storage.ts +39 -0
- package/template/src/lib/identity.ts +12 -10
- package/template/src/pages/api/v1/port.ts +172 -102
- package/template/src/pages/index.astro +26 -29
- package/template/src/tests/protocol.test.ts +162 -9
- package/template/src/tests/warp-links.test.ts +5 -8
- package/template/src/traffic-control.ts +104 -1
- package/template/wrangler.build.jsonc +1 -15
- package/template/wrangler.dev.jsonc +2 -15
- package/template/schema.sql +0 -22
|
@@ -35,15 +35,12 @@ const startPlanet = (planet: (typeof allPlanets)[0]) => {
|
|
|
35
35
|
const { id, name, url, port } = planet;
|
|
36
36
|
const inspectorPort = BASE_INSPECTOR_PORT + id;
|
|
37
37
|
|
|
38
|
-
//
|
|
39
|
-
console.log(`[${name}]
|
|
40
|
-
execSync(
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
stdio: "inherit",
|
|
45
|
-
},
|
|
46
|
-
);
|
|
38
|
+
// Clear any state left over from a prior test run
|
|
39
|
+
console.log(`[${name}] Clearing previous state...`);
|
|
40
|
+
execSync(`rm -rf .wrangler/state/test-planet-${id}`, {
|
|
41
|
+
cwd: PROJECT_ROOT,
|
|
42
|
+
stdio: "inherit",
|
|
43
|
+
});
|
|
47
44
|
|
|
48
45
|
const child = spawn(
|
|
49
46
|
"npx",
|
|
@@ -98,6 +95,48 @@ const startPlanet = (planet: (typeof allPlanets)[0]) => {
|
|
|
98
95
|
});
|
|
99
96
|
};
|
|
100
97
|
|
|
98
|
+
async function getQuorumCount(planetUrl: string): Promise<number> {
|
|
99
|
+
const res = await fetch(`${planetUrl}/api/v1/control-ws`);
|
|
100
|
+
if (!res.ok) return 0;
|
|
101
|
+
const events = (await res.json()) as any[];
|
|
102
|
+
return events.filter((e) => e.type === "QUORUM_REACHED").length;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function collectQuorumPlanIds(
|
|
106
|
+
planetUrls: string[],
|
|
107
|
+
): Promise<Set<string>> {
|
|
108
|
+
const ids = new Set<string>();
|
|
109
|
+
for (const url of planetUrls) {
|
|
110
|
+
try {
|
|
111
|
+
const res = await fetch(`${url}/api/v1/control-ws`);
|
|
112
|
+
if (res.ok) {
|
|
113
|
+
const events = (await res.json()) as any[];
|
|
114
|
+
for (const e of events) {
|
|
115
|
+
if (e.type === "QUORUM_REACHED" && e.plan_id) ids.add(e.plan_id);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
} catch {}
|
|
119
|
+
}
|
|
120
|
+
return ids;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function waitForPlanQuorum(
|
|
124
|
+
allUrls: string[],
|
|
125
|
+
planId: string,
|
|
126
|
+
label: string,
|
|
127
|
+
) {
|
|
128
|
+
for (let attempt = 0; attempt < 30; attempt++) {
|
|
129
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
130
|
+
const ids = await collectQuorumPlanIds(allUrls);
|
|
131
|
+
if (ids.has(planId)) {
|
|
132
|
+
console.log(`SUCCESS: ${label}`);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
console.log(`Waiting for ${label}... (attempt ${attempt + 1}/30)`);
|
|
136
|
+
}
|
|
137
|
+
throw new Error(`Timeout waiting for ${label}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
101
140
|
describe("Federated Planets Protocol", () => {
|
|
102
141
|
beforeAll(async () => {
|
|
103
142
|
console.log(`Starting ${NUM_PLANETS} planets...`);
|
|
@@ -155,4 +194,118 @@ describe("Federated Planets Protocol", () => {
|
|
|
155
194
|
|
|
156
195
|
expect(quorumReached).toBe(true);
|
|
157
196
|
}, 90000); // 90s test timeout
|
|
197
|
+
|
|
198
|
+
it("should reject a third shuttle when the mutual-neighbor limit (2) is reached", async () => {
|
|
199
|
+
// Towel 3 and Towel 4 are mutual neighbors of each other; limit is 2
|
|
200
|
+
const origin = allPlanets[2]; // Towel 3
|
|
201
|
+
const dest = allPlanets[3]; // Towel 4
|
|
202
|
+
const allUrls = allPlanets.map((p) => p.url);
|
|
203
|
+
|
|
204
|
+
const initiate = (shipId: string) =>
|
|
205
|
+
fetch(`${origin.url}/api/v1/port?action=initiate`, {
|
|
206
|
+
method: "POST",
|
|
207
|
+
headers: { "Content-Type": "application/json" },
|
|
208
|
+
body: JSON.stringify({
|
|
209
|
+
ship_id: shipId,
|
|
210
|
+
destination_url: dest.url,
|
|
211
|
+
departure_timestamp: Date.now(),
|
|
212
|
+
}),
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Shuttle 1
|
|
216
|
+
console.log("Initiating shuttle 1 from Towel 3 to Towel 4...");
|
|
217
|
+
const res1 = await initiate("SHUTTLE-A");
|
|
218
|
+
expect(res1.ok).toBe(true);
|
|
219
|
+
const plan1Id = ((await res1.json()) as any).plan.id;
|
|
220
|
+
console.log("Shuttle 1 plan:", plan1Id);
|
|
221
|
+
await waitForPlanQuorum(allUrls, plan1Id, "shuttle 1 quorum");
|
|
222
|
+
// Give origin's handleCommit time to write to its own DB after quorum is reached
|
|
223
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
224
|
+
|
|
225
|
+
// Shuttle 2
|
|
226
|
+
console.log("Initiating shuttle 2 from Towel 3 to Towel 4...");
|
|
227
|
+
const res2 = await initiate("SHUTTLE-B");
|
|
228
|
+
expect(res2.ok).toBe(true);
|
|
229
|
+
const plan2Id = ((await res2.json()) as any).plan.id;
|
|
230
|
+
console.log("Shuttle 2 plan:", plan2Id);
|
|
231
|
+
await waitForPlanQuorum(allUrls, plan2Id, "shuttle 2 quorum");
|
|
232
|
+
// Give origin's handleCommit time to write to its own DB after quorum is reached
|
|
233
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
234
|
+
|
|
235
|
+
// Shuttle 3 — should be rejected at origin
|
|
236
|
+
console.log("Initiating shuttle 3 (should be rejected)...");
|
|
237
|
+
const res3 = await initiate("SHUTTLE-C");
|
|
238
|
+
expect(res3.status).toBe(422);
|
|
239
|
+
const data3 = (await res3.json()) as any;
|
|
240
|
+
expect(data3.error).toBe("shuttle_limit_exceeded");
|
|
241
|
+
expect(data3.limit).toBe(2);
|
|
242
|
+
expect(data3.relationship).toBe("mutual_neighbors");
|
|
243
|
+
expect(data3.active_shuttles).toBe(2);
|
|
244
|
+
console.log("Shuttle 3 correctly rejected:", data3);
|
|
245
|
+
}, 180000); // 3 minutes for two quorum rounds
|
|
246
|
+
|
|
247
|
+
it("should refuse incoming travel at the destination when its shuttle limit is already reached", async () => {
|
|
248
|
+
// Use Towel 1 → Towel 3 (mutual neighbors, limit = 2).
|
|
249
|
+
// First establish 2 legitimate shuttles so Towel 3's DB is at the limit.
|
|
250
|
+
// Then use the test-only bypass header to skip Towel 1's origin check —
|
|
251
|
+
// the plan proceeds through consensus but Towel 3 refuses registration.
|
|
252
|
+
const origin = allPlanets[0]; // Towel 1
|
|
253
|
+
const dest = allPlanets[2]; // Towel 3
|
|
254
|
+
const allUrls = allPlanets.map((p) => p.url);
|
|
255
|
+
|
|
256
|
+
const initiate = (shipId: string, bypass = false) =>
|
|
257
|
+
fetch(`${origin.url}/api/v1/port?action=initiate`, {
|
|
258
|
+
method: "POST",
|
|
259
|
+
headers: {
|
|
260
|
+
"Content-Type": "application/json",
|
|
261
|
+
...(bypass ? { "X-Bypass-Shuttle-Limit": "true" } : {}),
|
|
262
|
+
},
|
|
263
|
+
body: JSON.stringify({
|
|
264
|
+
ship_id: shipId,
|
|
265
|
+
destination_url: dest.url,
|
|
266
|
+
departure_timestamp: Date.now(),
|
|
267
|
+
}),
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// Fill Towel 3's limit with 2 legitimate shuttles from Towel 1
|
|
271
|
+
console.log("Filling Towel 3 limit with 2 shuttles from Towel 1...");
|
|
272
|
+
const fillRes1 = await initiate("FILL-A");
|
|
273
|
+
expect(fillRes1.ok).toBe(true);
|
|
274
|
+
const fillPlan1 = ((await fillRes1.json()) as any).plan.id;
|
|
275
|
+
await waitForPlanQuorum(allUrls, fillPlan1, "fill shuttle 1 quorum");
|
|
276
|
+
|
|
277
|
+
const fillRes2 = await initiate("FILL-B");
|
|
278
|
+
expect(fillRes2.ok).toBe(true);
|
|
279
|
+
const fillPlan2 = ((await fillRes2.json()) as any).plan.id;
|
|
280
|
+
await waitForPlanQuorum(allUrls, fillPlan2, "fill shuttle 2 quorum");
|
|
281
|
+
|
|
282
|
+
// Now both Towel 1 and Towel 3 have count=2 for the 1↔3 pair.
|
|
283
|
+
// Use the bypass header to skip Towel 1's origin check — the plan enters
|
|
284
|
+
// consensus, but Towel 3 independently checks and refuses registration.
|
|
285
|
+
console.log(
|
|
286
|
+
"Initiating with bypass header — origin allows, destination should refuse...",
|
|
287
|
+
);
|
|
288
|
+
const response = await initiate("DEST-REFUSES", true);
|
|
289
|
+
expect(response.ok).toBe(true);
|
|
290
|
+
const planId = ((await response.json()) as any).plan.id;
|
|
291
|
+
console.log("Bypass plan initiated:", planId);
|
|
292
|
+
|
|
293
|
+
// Confirm no QUORUM_REACHED appears for this plan on any planet
|
|
294
|
+
console.log(
|
|
295
|
+
"Confirming destination refusal (no QUORUM_REACHED expected)...",
|
|
296
|
+
);
|
|
297
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
298
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
299
|
+
const quorumIds = await collectQuorumPlanIds(allUrls);
|
|
300
|
+
if (quorumIds.has(planId)) {
|
|
301
|
+
throw new Error(
|
|
302
|
+
`Plan ${planId} should have been refused by destination but reached quorum`,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
console.log(
|
|
306
|
+
`No QUORUM_REACHED seen (attempt ${attempt + 1}/10) — destination is refusing`,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
console.log("Confirmed: destination refused the travel plan");
|
|
310
|
+
}, 300000); // 5 minutes: two fill quorum rounds + 20s destination refusal confirmation
|
|
158
311
|
});
|
|
@@ -30,14 +30,11 @@ const cleanup = () => {
|
|
|
30
30
|
|
|
31
31
|
describe("Warp Links Configuration", () => {
|
|
32
32
|
beforeAll(async () => {
|
|
33
|
-
console.log(`[${TEST_NAME}]
|
|
34
|
-
execSync(
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
stdio: "inherit",
|
|
39
|
-
},
|
|
40
|
-
);
|
|
33
|
+
console.log(`[${TEST_NAME}] Clearing previous state...`);
|
|
34
|
+
execSync(`rm -rf .wrangler/state/warp-test`, {
|
|
35
|
+
cwd: PROJECT_ROOT,
|
|
36
|
+
stdio: "inherit",
|
|
37
|
+
});
|
|
41
38
|
|
|
42
39
|
console.log(`Starting ${TEST_NAME} on http://${TEST_HOST}:${TEST_PORT}...`);
|
|
43
40
|
|
|
@@ -6,7 +6,38 @@ export default class TrafficControl extends DurableObject {
|
|
|
6
6
|
|
|
7
7
|
constructor(state: any, env: any) {
|
|
8
8
|
super(state, env);
|
|
9
|
-
|
|
9
|
+
this.ctx.storage.sql.exec(`
|
|
10
|
+
CREATE TABLE IF NOT EXISTS travel_plans (
|
|
11
|
+
id TEXT PRIMARY KEY,
|
|
12
|
+
ship_id TEXT NOT NULL,
|
|
13
|
+
origin_url TEXT NOT NULL,
|
|
14
|
+
destination_url TEXT NOT NULL,
|
|
15
|
+
start_timestamp INTEGER NOT NULL,
|
|
16
|
+
end_timestamp INTEGER NOT NULL,
|
|
17
|
+
status TEXT CHECK(status IN ('PREPARING', 'PLAN_ACCEPTED')) NOT NULL DEFAULT 'PREPARING',
|
|
18
|
+
signatures TEXT NOT NULL
|
|
19
|
+
);
|
|
20
|
+
CREATE INDEX IF NOT EXISTS idx_travel_plans_active ON travel_plans (end_timestamp, origin_url, destination_url);
|
|
21
|
+
|
|
22
|
+
CREATE TABLE IF NOT EXISTS traffic_controllers (
|
|
23
|
+
planet_url TEXT PRIMARY KEY,
|
|
24
|
+
name TEXT NOT NULL,
|
|
25
|
+
space_port_url TEXT NOT NULL,
|
|
26
|
+
last_manifest_fetch INTEGER NOT NULL
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
CREATE TABLE IF NOT EXISTS identity (
|
|
30
|
+
key TEXT PRIMARY KEY,
|
|
31
|
+
value TEXT NOT NULL
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
CREATE TABLE IF NOT EXISTS consensus_plans (
|
|
35
|
+
id TEXT PRIMARY KEY,
|
|
36
|
+
data TEXT NOT NULL,
|
|
37
|
+
expires_at INTEGER NOT NULL
|
|
38
|
+
);
|
|
39
|
+
`);
|
|
40
|
+
console.log("[TrafficControl] Initialized with SQLite storage");
|
|
10
41
|
}
|
|
11
42
|
|
|
12
43
|
async fetch(request: Request) {
|
|
@@ -20,6 +51,10 @@ export default class TrafficControl extends DurableObject {
|
|
|
20
51
|
return new Response("OK");
|
|
21
52
|
}
|
|
22
53
|
|
|
54
|
+
if (url.pathname === "/storage" && request.method === "POST") {
|
|
55
|
+
return this.handleStorage(request);
|
|
56
|
+
}
|
|
57
|
+
|
|
23
58
|
if (request.headers.get("Upgrade") === "websocket") {
|
|
24
59
|
const pair = new WebSocketPair();
|
|
25
60
|
const [client, server] = Object.values(pair);
|
|
@@ -40,6 +75,74 @@ export default class TrafficControl extends DurableObject {
|
|
|
40
75
|
});
|
|
41
76
|
}
|
|
42
77
|
|
|
78
|
+
private async handleStorage(request: Request): Promise<Response> {
|
|
79
|
+
const body: any = await request.json();
|
|
80
|
+
const { action } = body;
|
|
81
|
+
|
|
82
|
+
switch (action) {
|
|
83
|
+
case "getIdentity": {
|
|
84
|
+
const pub = this.ctx.storage.sql
|
|
85
|
+
.exec("SELECT value FROM identity WHERE key = 'identity_public'")
|
|
86
|
+
.toArray();
|
|
87
|
+
const priv = this.ctx.storage.sql
|
|
88
|
+
.exec("SELECT value FROM identity WHERE key = 'identity_private'")
|
|
89
|
+
.toArray();
|
|
90
|
+
return Response.json({
|
|
91
|
+
public: pub.length > 0 ? (pub[0] as any).value : null,
|
|
92
|
+
private: priv.length > 0 ? (priv[0] as any).value : null,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
case "setIdentity": {
|
|
97
|
+
this.ctx.storage.sql.exec(
|
|
98
|
+
"INSERT OR REPLACE INTO identity (key, value) VALUES ('identity_public', ?), ('identity_private', ?)",
|
|
99
|
+
body.publicKey,
|
|
100
|
+
body.privateKey,
|
|
101
|
+
);
|
|
102
|
+
return Response.json({ success: true });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
case "savePlan": {
|
|
106
|
+
const expiresAt = Date.now() + 3600 * 1000;
|
|
107
|
+
this.ctx.storage.sql.exec(
|
|
108
|
+
"INSERT OR REPLACE INTO consensus_plans (id, data, expires_at) VALUES (?, ?, ?)",
|
|
109
|
+
body.planId,
|
|
110
|
+
body.data,
|
|
111
|
+
expiresAt,
|
|
112
|
+
);
|
|
113
|
+
return Response.json({ success: true });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
case "getPlan": {
|
|
117
|
+
const rows = this.ctx.storage.sql
|
|
118
|
+
.exec(
|
|
119
|
+
"SELECT data FROM consensus_plans WHERE id = ? AND expires_at > ?",
|
|
120
|
+
body.planId,
|
|
121
|
+
Date.now(),
|
|
122
|
+
)
|
|
123
|
+
.toArray();
|
|
124
|
+
return Response.json({
|
|
125
|
+
data: rows.length > 0 ? (rows[0] as any).data : null,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
case "query": {
|
|
130
|
+
const results = this.ctx.storage.sql
|
|
131
|
+
.exec(body.sql, ...(body.params || []))
|
|
132
|
+
.toArray();
|
|
133
|
+
return Response.json({ results });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
case "exec": {
|
|
137
|
+
this.ctx.storage.sql.exec(body.sql, ...(body.params || []));
|
|
138
|
+
return Response.json({ success: true });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
default:
|
|
142
|
+
return Response.json({ error: "Unknown action" }, { status: 400 });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
43
146
|
broadcast(message: string) {
|
|
44
147
|
for (const ws of this.sessions) {
|
|
45
148
|
try {
|
|
@@ -5,20 +5,6 @@
|
|
|
5
5
|
"directory": "dist/client",
|
|
6
6
|
"binding": "STATIC_ASSETS",
|
|
7
7
|
},
|
|
8
|
-
"d1_databases": [
|
|
9
|
-
{
|
|
10
|
-
"binding": "DB",
|
|
11
|
-
"database_name": "planet_db",
|
|
12
|
-
"database_id": "your-d1-id-here", // To be replaced during deployment
|
|
13
|
-
"migrations_dir": "migrations",
|
|
14
|
-
},
|
|
15
|
-
],
|
|
16
|
-
"kv_namespaces": [
|
|
17
|
-
{
|
|
18
|
-
"binding": "KV",
|
|
19
|
-
"id": "your-kv-id-here", // To be replaced during deployment
|
|
20
|
-
},
|
|
21
|
-
],
|
|
22
8
|
"durable_objects": {
|
|
23
9
|
"bindings": [
|
|
24
10
|
{
|
|
@@ -30,7 +16,7 @@
|
|
|
30
16
|
"migrations": [
|
|
31
17
|
{
|
|
32
18
|
"tag": "v1",
|
|
33
|
-
"
|
|
19
|
+
"new_sqlite_classes": ["TrafficControl"],
|
|
34
20
|
},
|
|
35
21
|
],
|
|
36
22
|
}
|
|
@@ -6,20 +6,6 @@
|
|
|
6
6
|
"directory": "dist/client",
|
|
7
7
|
"binding": "STATIC_ASSETS",
|
|
8
8
|
},
|
|
9
|
-
"d1_databases": [
|
|
10
|
-
{
|
|
11
|
-
"binding": "DB",
|
|
12
|
-
"database_name": "planet_db",
|
|
13
|
-
"database_id": "your-d1-id-here",
|
|
14
|
-
"migrations_dir": "migrations",
|
|
15
|
-
},
|
|
16
|
-
],
|
|
17
|
-
"kv_namespaces": [
|
|
18
|
-
{
|
|
19
|
-
"binding": "KV",
|
|
20
|
-
"id": "your-kv-id-here",
|
|
21
|
-
},
|
|
22
|
-
],
|
|
23
9
|
"durable_objects": {
|
|
24
10
|
"bindings": [
|
|
25
11
|
{
|
|
@@ -31,11 +17,12 @@
|
|
|
31
17
|
"vars": {
|
|
32
18
|
"WARP_MS_PER_FY": "10000",
|
|
33
19
|
"DEPARTURE_BUFFER_MS": "5000",
|
|
20
|
+
"ALLOW_TEST_SHUTTLE_BYPASS": "true",
|
|
34
21
|
},
|
|
35
22
|
"migrations": [
|
|
36
23
|
{
|
|
37
24
|
"tag": "v1",
|
|
38
|
-
"
|
|
25
|
+
"new_sqlite_classes": ["TrafficControl"],
|
|
39
26
|
},
|
|
40
27
|
],
|
|
41
28
|
}
|
package/template/schema.sql
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
-- Travel Plans Table: Tracks all journeys (active and historical)
|
|
2
|
-
CREATE TABLE IF NOT EXISTS travel_plans (
|
|
3
|
-
id TEXT PRIMARY KEY,
|
|
4
|
-
ship_id TEXT NOT NULL,
|
|
5
|
-
origin_url TEXT NOT NULL,
|
|
6
|
-
destination_url TEXT NOT NULL,
|
|
7
|
-
start_timestamp INTEGER NOT NULL,
|
|
8
|
-
end_timestamp INTEGER NOT NULL,
|
|
9
|
-
status TEXT CHECK(status IN ('PREPARING', 'PLAN_ACCEPTED')) NOT NULL DEFAULT 'PREPARING',
|
|
10
|
-
signatures TEXT NOT NULL -- JSON array of cryptographic signatures
|
|
11
|
-
);
|
|
12
|
-
|
|
13
|
-
-- Index for fast retrieval of active plans (not yet arrived)
|
|
14
|
-
CREATE INDEX IF NOT EXISTS idx_travel_plans_active ON travel_plans (end_timestamp, origin_url, destination_url);
|
|
15
|
-
|
|
16
|
-
-- Traffic Controllers Cache: Known neighbors with space ports
|
|
17
|
-
CREATE TABLE IF NOT EXISTS traffic_controllers (
|
|
18
|
-
planet_url TEXT PRIMARY KEY,
|
|
19
|
-
name TEXT NOT NULL,
|
|
20
|
-
space_port_url TEXT NOT NULL,
|
|
21
|
-
last_manifest_fetch INTEGER NOT NULL
|
|
22
|
-
);
|