@protobase/cli 0.73.1 → 0.73.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/dist/build/service-worker-push-code.js +36 -8
- package/dist/cli/package.js +1 -1
- package/dist/doctor/view-shapes.js +5 -1
- package/package.json +8 -8
- package/skills/protobase/references/features/notifications.md +1 -1
- package/skills/protobase/references/features/rest-api.md +1 -0
- package/skills/protobase/references/reference/access.md +13 -0
- package/skills/protobase/references/reference/api.md +21 -2
- package/skills/protobase/references/reference/mcp.md +7 -1
- package/skills/protobase/references/reference/notifications.md +28 -3
- package/skills/protobase/references/reference/ui-config.md +1 -1
|
@@ -8,9 +8,13 @@ import { pushRecord } from "@protobase/schema/internal";
|
|
|
8
8
|
* names the subscription's endpoint and the push names the record's user. A push without a title shows the app's
|
|
9
9
|
* name alone. A push with no record, or for another endpoint or user, shows nothing: the worker closes what it showed,
|
|
10
10
|
* clears the badge, drops the record and unsubscribes.
|
|
11
|
-
* -
|
|
12
|
-
* a
|
|
13
|
-
*
|
|
11
|
+
* - A push's tap opens its `notification.navigate`, or its `url` when it has none, and each of its `notification.actions`
|
|
12
|
+
* shows as a button that opens the action's own `navigate`, or the tap's url when it has none. Nothing else in the
|
|
13
|
+
* push, such as its `data`, is kept for a tap.
|
|
14
|
+
* - `notificationclick` opens the tapped action's url, or the notification's for a tap on no action it knows, only when
|
|
15
|
+
* it is an absolute `http` or `https` URL, parsed without a base: on the worker's own origin in a window of the app,
|
|
16
|
+
* focused and navigated there, or in a new window when none is open; on any other origin always in a new window.
|
|
17
|
+
* Anything else opens nothing, whoever showed the notification, and a failing action never falls back to another url.
|
|
14
18
|
* - `pushsubscriptionchange` marks an existing record for renewal, which the helper registers once its user is signed
|
|
15
19
|
* in; the worker has no token to do so itself.
|
|
16
20
|
*/
|
|
@@ -59,6 +63,22 @@ const pushData = (data) => {
|
|
|
59
63
|
}
|
|
60
64
|
|
|
61
65
|
const text = (value) => (typeof value === 'string' ? value : undefined)
|
|
66
|
+
const plainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
67
|
+
|
|
68
|
+
// The checked urls a tap may open: the notification's, and per action [action, url], with null for a navigate that
|
|
69
|
+
// fails, so its button opens nothing. Pairs rather than an object keyed by the push's action names.
|
|
70
|
+
const tapTargets = (push) => {
|
|
71
|
+
const notification = plainObject(push.notification) ? push.notification : {}
|
|
72
|
+
const url = notification.navigate !== undefined ? tapUrl(notification.navigate) : tapUrl(push.url)
|
|
73
|
+
const actions = (Array.isArray(notification.actions) ? notification.actions : []).filter(
|
|
74
|
+
(action) => plainObject(action) && text(action.action) !== undefined && text(action.title) !== undefined,
|
|
75
|
+
)
|
|
76
|
+
return {
|
|
77
|
+
url,
|
|
78
|
+
buttons: actions.map((action) => ({ action: action.action, title: action.title })),
|
|
79
|
+
actions: actions.map((action) => [action.action, action.navigate === undefined ? url ?? null : tapUrl(action.navigate) ?? null]),
|
|
80
|
+
}
|
|
81
|
+
}
|
|
62
82
|
|
|
63
83
|
// This browser is no longer registered for anyone: nothing it showed stays, and it gets no more pushes.
|
|
64
84
|
const forgetPush = async () => {
|
|
@@ -75,11 +95,12 @@ const showPush = async (data) => {
|
|
|
75
95
|
const push = pushData(data)
|
|
76
96
|
if (!push || text(push.title) === undefined) return self.registration.showNotification(text(record.appName) || self.location.host, {})
|
|
77
97
|
if (push.user !== record.userId) return forgetPush()
|
|
78
|
-
const url =
|
|
98
|
+
const { url, buttons, actions } = tapTargets(push)
|
|
79
99
|
const options = {}
|
|
80
100
|
if (text(push.body) !== undefined) options.body = push.body
|
|
81
101
|
if (text(push.tag) !== undefined) options.tag = push.tag
|
|
82
|
-
if (
|
|
102
|
+
if (buttons.length > 0) options.actions = buttons
|
|
103
|
+
if (url !== undefined || actions.length > 0) options.data = { ...(url !== undefined && { url }), ...(actions.length > 0 && { actions }) }
|
|
83
104
|
if (typeof push.badge === 'number' && self.navigator.setAppBadge) await self.navigator.setAppBadge(push.badge)
|
|
84
105
|
return self.registration.showNotification(push.title, options)
|
|
85
106
|
}
|
|
@@ -98,15 +119,22 @@ const openInApp = async (url) => {
|
|
|
98
119
|
}
|
|
99
120
|
}
|
|
100
121
|
|
|
101
|
-
|
|
122
|
+
// The url a tap opens, checked again: page code may show a notification with any data.
|
|
123
|
+
const tappedUrl = (data, action) => {
|
|
124
|
+
if (!plainObject(data)) return undefined
|
|
125
|
+
const pair = action && Array.isArray(data.actions) ? data.actions.find((entry) => Array.isArray(entry) && entry[0] === action) : undefined
|
|
126
|
+
return tapUrl(pair ? pair[1] : data.url)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const openTap = (notification, action) => {
|
|
102
130
|
notification.close()
|
|
103
|
-
const url =
|
|
131
|
+
const url = tappedUrl(notification.data, action)
|
|
104
132
|
if (url === undefined) return Promise.resolve()
|
|
105
133
|
return new URL(url).origin === self.location.origin ? openInApp(url) : self.clients.openWindow(url)
|
|
106
134
|
}
|
|
107
135
|
|
|
108
136
|
self.addEventListener('push', (event) => event.waitUntil(showPush(event.data)))
|
|
109
|
-
self.addEventListener('notificationclick', (event) => event.waitUntil(openTap(event.notification)))
|
|
137
|
+
self.addEventListener('notificationclick', (event) => event.waitUntil(openTap(event.notification, event.action)))
|
|
110
138
|
self.addEventListener('pushsubscriptionchange', (event) => {
|
|
111
139
|
event.waitUntil(readPushRecord().then((record) => record && writePushRecord({ ...record, renew: true })))
|
|
112
140
|
})
|
package/dist/cli/package.js
CHANGED
|
@@ -47,11 +47,15 @@ const relatedShapes = (item, owner, models) => {
|
|
|
47
47
|
const extra = item.filter ? ` AND (${item.filter})` : "";
|
|
48
48
|
const via = item.through && models[item.through.resource];
|
|
49
49
|
const viaField = via && via.fields[item.through.field];
|
|
50
|
+
const selects = via && via.fields[item.through.key ?? via.primaryKey[0]];
|
|
50
51
|
const join = via && viaField ? [{
|
|
51
52
|
resource: via.name,
|
|
52
53
|
request: request(via.name, `${viaField.name} = …${item.through.filter ? ` AND (${item.through.filter})` : ""}`, void 0),
|
|
53
54
|
from,
|
|
54
|
-
query: {
|
|
55
|
+
query: {
|
|
56
|
+
filter: all([compare(viaField), configFilter(via, item.through.filter)]),
|
|
57
|
+
...selects && { selects }
|
|
58
|
+
}
|
|
55
59
|
}] : [];
|
|
56
60
|
const matched = item.through ? anyOf(match) : compare(match);
|
|
57
61
|
const text = item.through ? `${match.name} in (…)` : `${match.name} = …`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/cli",
|
|
3
|
-
"version": "0.73.
|
|
3
|
+
"version": "0.73.3",
|
|
4
4
|
"description": "The protobase command: scaffold, doctor, dev, build and serve a Protobase project.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -43,13 +43,13 @@
|
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"@better-auth/core": "^1.7.12",
|
|
45
45
|
"@hono/node-server": "^2.1.3",
|
|
46
|
-
"@protobase/config": "0.73.
|
|
47
|
-
"@protobase/layout": "0.73.
|
|
48
|
-
"@protobase/query": "0.73.
|
|
49
|
-
"@protobase/schema": "0.73.
|
|
50
|
-
"@protobase/server": "0.73.
|
|
51
|
-
"@protobase/ui": "0.73.
|
|
52
|
-
"@protobase/workflows": "0.73.
|
|
46
|
+
"@protobase/config": "0.73.3",
|
|
47
|
+
"@protobase/layout": "0.73.3",
|
|
48
|
+
"@protobase/query": "0.73.3",
|
|
49
|
+
"@protobase/schema": "0.73.3",
|
|
50
|
+
"@protobase/server": "0.73.3",
|
|
51
|
+
"@protobase/ui": "0.73.3",
|
|
52
|
+
"@protobase/workflows": "0.73.3",
|
|
53
53
|
"@react-email/render": "^2.1.0",
|
|
54
54
|
"@scalar/hono-api-reference": "^0.12.10",
|
|
55
55
|
"@sentry/vite-plugin": "^11.10.0",
|
|
@@ -11,7 +11,7 @@ A bell in the top bar and push to browsers and phones, for what your app tells i
|
|
|
11
11
|
- [Sending notifications](../reference/notifications.md#sending) (beta): Your code sends a notification to users or to devices as a background job, queued in the same transaction as a write, and says on each one whether it shows in the inbox.
|
|
12
12
|
- [Notification history](../reference/notifications.md#history) (beta): Each user's inbox notifications are kept with when they were read, and the API lists them, counts the unread ones and marks them read.
|
|
13
13
|
- [Web Push](../reference/notifications.md#web-push) (beta): Notifications reach desktop and Android browsers and home-screen web apps on iPhone through Web Push, with keys you generate.
|
|
14
|
-
- Push on Android (
|
|
14
|
+
- [Push on Android](../reference/notifications.md#android) (in development): The mobile app receives notifications on Android through Firebase Cloud Messaging, set up by `protobase mobile` from a `google-services.json` in the project.
|
|
15
15
|
- Push on iOS (coming soon): Notifications reach the iOS build of the app on a user's iPhones and iPads.
|
|
16
16
|
- [Do not disturb](../reference/status.md#do-not-disturb) (beta): While someone is on do not disturb, sends push nothing to their devices and keep inbox notifications in the bell for later.
|
|
17
17
|
- [Notification bell](../reference/notifications.md#the-bell) (beta): The top bar shows how many inbox notifications are unread; its panel opens each one, marks them read and turns push on or off for this device.
|
|
@@ -7,6 +7,7 @@ The API every resource gets, with filters, writes, errors and the typed client.
|
|
|
7
7
|
- [Generated REST API](../reference/api.md#routes): List, get, create, update and delete endpoints for every resource, following the AIP API design guidelines.
|
|
8
8
|
- [OpenAPI and reference](../reference/api.md#meta-and-openapi-per-caller): An OpenAPI 3.1 document and an API reference page, each showing only what the caller may see and do.
|
|
9
9
|
- [Filters](../reference/api.md#filters): AIP-160 filters and ordering, with word search across a resource's search fields, lists of values and relative dates.
|
|
10
|
+
- [Filtering through related records](../reference/api.md#filtering-through-related-records): A list asks for the records that belong to another with a subfilter, such as an objective's tasks, in one read under the caller's access, instead of sending a list of keys.
|
|
10
11
|
- [Counts, facets and charts](../reference/api.md#routes): Endpoints for value counts, grouped sums and averages, records over time, histograms and paging to any row.
|
|
11
12
|
- [Map queries](../reference/api.md#maps): The records inside a map's view, or counts per area when there are many.
|
|
12
13
|
- [Related names and photos](../reference/api.md#related-names): A list response can carry the names of related records and each record's first photos.
|
|
@@ -183,6 +183,19 @@ export const operations = resource('operations')
|
|
|
183
183
|
- **The parent is locked until the write commits** (`FOR NO KEY UPDATE`), so a VM handed to someone else or deleted at the same moment is waited for and checked again. That is the cost: writes of records under one parent, and through an `update` parent under one ancestor, run one after another. The server's database role needs `UPDATE` on the parent's table to take the lock. Two writes that lock the same records in opposite orders, such as two batches writing under VM A then B and B then A, can deadlock: Postgres rolls one back, which answers `409` and writes nothing, and the client sends it again.
|
|
184
184
|
- The record page and `/api/meta` count the check, so the admin app offers no Edit or Delete the server would refuse. `/api/sb/rest/v1` does not use Protobase's access rules at all, so this check is not part of it.
|
|
185
185
|
|
|
186
|
+
## Related records in filters
|
|
187
|
+
|
|
188
|
+
A list's filter can reach through other records with [`related(field, resource.key, (subfilter))`](api.md#filtering-through-related-records). It may do only what the caller could do in two steps, listing `resource` with the subfilter and then filtering with `in(field, ...)`, so it never tells a row or field they cannot read.
|
|
189
|
+
|
|
190
|
+
- **The caller's list of `resource`.** The term is one more condition on the read, and its subquery is that list: their tenant, row filter, soft delete and `read` parents there. The outer list's own tenant and row filter stay, so a term can only remove rows. `show_deleted` reaches the outer rows only.
|
|
191
|
+
- **The caller's fields.** The subfilter and `key` are checked against the resource as the caller sees it, so a hidden, sensitive or write-only field is refused like an unknown one, and `search("text")` in a subfilter reads only the search fields they read.
|
|
192
|
+
- **Nothing about what they cannot list.** A resource that does not exist, one they cannot list, one whose `read` or `list` rule [reads the record](#rules-that-read-the-record), a tenant-scoped one for a caller without a tenant, a wrong `key`, and fields that do not point at one resource all answer the same `400` `Unknown field`. A term in the wrong place or over the limits is refused before any of that is looked at.
|
|
193
|
+
- **The session's own organization.** A term reads that organization alone, and a list [across organizations](#lists-across-organizations) with a term falls back to it too.
|
|
194
|
+
|
|
195
|
+
A link record the caller reads can narrow a list by a record they cannot read: with `related(project_id, memberships.project_id)`, a ticket matches through a membership of a project hidden from them, because they could read that membership and that ticket anyway. Going through `projects` instead, `related(project_id, projects.id)`, keeps to the projects they read. `total_size_estimate` comes from the planner's statistics of the whole table, as on any list.
|
|
196
|
+
|
|
197
|
+
Only a read of the API resolves `related()`: access rules, role filters and the other places a filter is stored refuse it. <gap>An access rule cannot filter through related records because `related()` in rules is not implemented.</gap>
|
|
198
|
+
|
|
186
199
|
## Lists across organizations
|
|
187
200
|
|
|
188
201
|
:::caution[Beta]
|
|
@@ -143,7 +143,26 @@ The queue is an `AuditQueue`, `{ publish(event): Promise<void> }`, the default e
|
|
|
143
143
|
|
|
144
144
|
## Filters
|
|
145
145
|
|
|
146
|
-
`filter` and `order_by` are AIP-160 and AIP-132 text, checked against the model (filterable and sortable fields only). Functions: `in(field, v, ...)`, `search("text")` and bare words (every word, ignoring case, anywhere inside one of the resource's search fields, so `port` finds "Portfolio"; `%`, `_` and `\` are literal; a [`digitsEnd`](data-config.md#resource) field matches only the end of its digits), `similar(field, "text")`, `regex(field, "pattern")`, `isNull(field)`, `now()` with offsets such as `now() - 7d
|
|
146
|
+
`filter` and `order_by` are AIP-160 and AIP-132 text, checked against the model (filterable and sortable fields only). Functions: `in(field, v, ...)`, `search("text")` and bare words (every word, ignoring case, anywhere inside one of the resource's search fields, so `port` finds "Portfolio"; `%`, `_` and `\` are literal; a [`digitsEnd`](data-config.md#resource) field matches only the end of its digits), `similar(field, "text")`, `regex(field, "pattern")`, `isNull(field)`, `now()` with offsets such as `now() - 7d`, and [`related(field, resource.key, (filter))`](#filtering-through-related-records). A filter is capped at 10,000 characters in a query string and 200,000 in a `:search` body. A proxy in front of the API can refuse a long URL sooner with a `431`, counting the headers too: Protobase Cloud refused about 8,000 characters. So send a long filter to `:search`, as `client.list` does when its query string passes 4,000 characters.
|
|
147
|
+
|
|
148
|
+
### Filtering through related records
|
|
149
|
+
|
|
150
|
+
`related(field, resource.key, (subfilter))` keeps the records whose `field` holds the `key` of a record of `resource` that meets the subfilter, in one read, so a client never reads the keys first and sends them back:
|
|
151
|
+
|
|
152
|
+
```text
|
|
153
|
+
task_claims related(task_id, objective_tasks.task_id, (objective_id = 40)) AND claimed_at >= now() - 7d
|
|
154
|
+
tasks related(id, objective_tasks.task_id, (objective_id = 40)) AND state != "done"
|
|
155
|
+
preconditions related(task_id, tasks.id, (state != "done" AND related(id, objective_tasks.task_id, (objective_id = 40))))
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
It works in the `filter` of a list, `:search`, `:seek`, `:facets`, `:aggregate`, `:series`, `:histogram` and `:map`, in `/api/v1` and in MCP's `list_records` and `count_records`, and `where.related('task_id', 'objective_tasks', 'task_id', where.eq('objective_id', 40))` builds it in code.
|
|
159
|
+
|
|
160
|
+
- `field` is a filterable field of the resource read, and `key` one of `resource`. Both point at one resource: each is a relation to it, or its own single key. A null `field` does not match.
|
|
161
|
+
- It is a term ANDed at the top of the filter or of a subfilter. `NOT` and `OR` refuse it, as does anything else that takes a filter: access rules, role filters, a view's opening filter, events, subscriptions, workflow conditions and page conditions.
|
|
162
|
+
- A filter holds at most 3 terms, those in subfilters included, and nests at most 2 levels.
|
|
163
|
+
- It may do only what the caller could do in two steps, listing `resource` with the subfilter and then filtering with `in(field, ...)`: the [access](access.md#related-records-in-filters) rules apply to both sides, and a resource the caller cannot list answers as one that does not exist.
|
|
164
|
+
- The [scan guard](#safety) judges both tables. Besides `field` and the subfilter's columns, `key` needs a btree index under what the subfilter pins.
|
|
165
|
+
- A [list across organizations](access.md#lists-across-organizations) with a term reads the session's own organization alone, and so does the term.
|
|
147
166
|
|
|
148
167
|
## Writes
|
|
149
168
|
|
|
@@ -202,7 +221,7 @@ The API sends no CORS headers until it has something to allow: other websites in
|
|
|
202
221
|
|
|
203
222
|
- Every request runs in a transaction with `SET LOCAL statement_timeout` (`statementTimeoutMs`, default 1 s), so a slow query fails where it runs instead of holding a connection that other requests wait for. List, search, get, histogram, series, map and seek transactions are read-only. A query that runs longer is stopped: a `504` `statement-timeout` that names the request, its limit and the ways out, such as `GET /api/v1/orders ran a query longer than 1 s, so the database stopped it. Make the query faster, such as with an index or a narrower filter, or move the work into a function or job with longQueries: true, whose queries may run up to 30 s.` It goes to the app's error hook (`hooks/error.ts`, `createAdmin`'s `options.onUnhandledError`) and the [error tracker](#error-reporting) like a `500`. A read that needs longer has no way to opt out: give it an index, or move it into a [function](../guides/api-functions.md#long-queries) or [job](jobs.md) with `longQueries`.
|
|
204
223
|
- Under `protobase dev` and `serve`, a request waits at most 2 seconds for a free database connection. When every connection stays busy that long, it is a `503` `connections-busy` with `Retry-After: 1` that names the request, reported the same way, rather than a request that hangs behind slow ones. A request that finds room for a new connection waits up to 30 seconds for it to open, such as while a database that scaled to zero wakes.
|
|
205
|
-
- **Scan guard**: a list, `:search`, `:seek`, `:map` page, card photos, `:facets` and `:aggregate` read only columns an index serves, so they stay cheap however many rows match. The server reads each table's valid indexes from the catalog once, then checks every column the caller's filter, the role's row filter, the sort's first field, a facet's or an aggregate's grouped field
|
|
224
|
+
- **Scan guard**: a list, `:search`, `:seek`, `:map` page, card photos, `:facets` and `:aggregate` read only columns an index serves, so they stay cheap however many rows match. The server reads each table's valid indexes from the catalog once, then checks every column the caller's filter, the role's row filter, the sort's first field, a facet's or an aggregate's grouped field a [`read` parent](access.md#records-that-belong-to-another)'s relation read and a [`related()`](#filtering-through-related-records) term's `field`, the columns of the parent's own row filter against the parent's table, and the subfilter's columns and `key` of a term against its resource's table:
|
|
206
225
|
- Comparisons, `in`, null checks, sorts and facets need a btree index with the column as a key, every key before it pinned by the query: by `=` or `in` in the filter (outside `OR` and `NOT`), by the tenant or by soft delete. `(for_sale, status, published_at desc)` serves `for_sale = true AND status = "available"` sorted by `published_at desc`, but not `status` alone. `=` on text may use a pg_trgm index instead.
|
|
207
226
|
- Patterns (`name = "*port*"`), `regex()`, `similar()` and `search()` need a pg_trgm (GIN or GiST) index on each column they read, a `digitsEnd` search field one on its digits; JSON `field:value` needs a GIN index.
|
|
208
227
|
- A partial index counts for nothing, and a resource on a plain view or a foreign table, which has no indexes of its own, is not checked.
|
|
@@ -50,7 +50,7 @@ Each tool reads as the signed-in person: their role's access rules, row filters,
|
|
|
50
50
|
| Tool | What it does |
|
|
51
51
|
| --- | --- |
|
|
52
52
|
| `list_resources` | The resources the person may read, kept small for a client's first call: each one's name, label, the first line of its help, key and whether it soft deletes (`soft_delete`), without its fields. |
|
|
53
|
-
| `describe_resource` | One resource the person may read, with its fields: each field's type, label, help, enum values, the resource a relation links to, and whether it is optional, read-only and taken by filters and sorts. It also names the resource's `list_fields`, what `list_records` returns when not told which. |
|
|
53
|
+
| `describe_resource` | One resource the person may read, with its fields: each field's type, label, help, enum values, the resource a relation links to, and whether it is optional, read-only and taken by filters and sorts. `linked_from` names the relations of the other resources the person reads that point at this one. It also names the resource's `list_fields`, what `list_records` returns when not told which. |
|
|
54
54
|
| `list_records` | A page of a resource's records: `fields`, `filter` in the API's [filter syntax](api.md#filters), `order_by`, `page_size` (20 by default, at most 100) and `page_token`, with `next_page_token` for the next page and `total_size_estimate`, an estimate rather than an exact count. `show_deleted: true` also returns soft-deleted records. |
|
|
55
55
|
| `count_records` | How many records a `filter` finds, without returning them: `{ count }` as the API's `count=exact` counts, or `{ more_than: 10000, estimate }` when more match. With `group_by`, a filterable field, the count per value instead, as [`:aggregate`](api.md#routes) gives it: `{ group_by, groups: [{ value, count }] }`, largest first, adding up to what the filter finds, the first `limit` (100 by default, at most 1,000), with `label` naming a relation's values by a field of the records they link to. A date or timestamp `group_by` counts per day over the last 30 days. `show_deleted: true` also counts soft-deleted records. |
|
|
56
56
|
| `get_record` | One record by its key, with its ETag, holding the `fields` asked for or, without them, every field. A composite key is passed as the values of its fields, in order. `show_deleted: true` also finds a soft-deleted record. |
|
|
@@ -73,6 +73,12 @@ The server tells each client this when it connects, in its instructions, and the
|
|
|
73
73
|
{ "resource": "tasks", "key": "42", "fields": ["brief"] }
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
+
To find records through related ones, a client filters with [`related()`](api.md#filtering-through-related-records) in one call instead of reading the keys first and sending them back:
|
|
77
|
+
|
|
78
|
+
```json
|
|
79
|
+
{ "resource": "task_claims", "filter": "related(task_id, objective_tasks.task_id, (objective_id = 40))" }
|
|
80
|
+
```
|
|
81
|
+
|
|
76
82
|
A refusal, such as a resource the person may not read, a bad filter or a field the person may not read, comes back to the client as text it can act on, `{ "error": ..., "message": ... }`.
|
|
77
83
|
|
|
78
84
|
### Comments
|
|
@@ -67,7 +67,7 @@ The path comes from `devicesPath('/api', id)`, and the name of its id parameter
|
|
|
67
67
|
## Sending
|
|
68
68
|
|
|
69
69
|
:::caution[Beta]
|
|
70
|
-
|
|
70
|
+
Browsers receive push, and Android devices through [FCM](#android) once the mobile app registers them, which is coming soon; push on iOS is coming soon. <gap>A send can reach a device twice, or keep an entry twice in a history store of your own, when a worker stops mid-send because saving the send's progress in the same step as the push is not implemented.</gap>
|
|
71
71
|
:::
|
|
72
72
|
|
|
73
73
|
[Send notifications](../guides/push-notifications.md) sends one from a record change, step by step. You send a notification from your server code, such as a job, a hook, an event subscription or an API function, with `sendNotification(db, to, message)` from `@protobase/server`. It is queued as a background job in `db`'s transaction when `db` is one, so it goes out only once your write commits, and nothing goes out when the transaction rolls back. A running job also has it as `sendNotification` in its context, beside `mail`, when the app has `notifications`.
|
|
@@ -207,10 +207,35 @@ export const PushButton = () => {
|
|
|
207
207
|
- Only someone signed in as themselves can turn push on: a visitor's click and staff signed in as the user do nothing.
|
|
208
208
|
- On iPhone and iPad, push works in the app added to the Home Screen; a browser tab answers `unsupported`.
|
|
209
209
|
- Signing out turns push off in this browser, and so does someone else signing in on it: what the browser showed is closed, the app's badge cleared, and the device removed. Signing in again as the same user keeps it on. Each push names the user its device is registered for, and the browser shows nothing of a push for another user than the one it holds push for.
|
|
210
|
-
- A tap on a notification
|
|
210
|
+
- A tap on a notification opens its `platform.web.payload` `notification.navigate`, or its `url` when it has none, and each of the payload's `notification.actions` shows as a button that opens its own `navigate`, or the notification's url when it has none. The browser checks each url again as the server does and opens nothing for one that fails; no other field, such as `data`, is ever opened. A url on your app's origin opens that page in the app, in an open window of it or a new one; any other url opens in a new tab.
|
|
211
211
|
|
|
212
212
|
`protobase serve` and `protobase dev` both serve the service worker that shows notifications, `protobase dev` only when the app has `notifications`.
|
|
213
213
|
|
|
214
|
+
## Android
|
|
215
|
+
|
|
216
|
+
:::caution[In development]
|
|
217
|
+
The mobile app does not register for push on Android yet.
|
|
218
|
+
:::
|
|
219
|
+
|
|
220
|
+
Android devices receive push through Firebase Cloud Messaging's HTTP v1 API. You create a service account key in your Firebase project (Project settings, Service accounts, Generate new private key) and set its JSON as `PROTOBASE_FCM_SERVICE_ACCOUNT`. Without it, Android devices are skipped. Setting the mobile app up is coming soon.
|
|
221
|
+
|
|
222
|
+
JSON that is not a service account key, a `project_id` that is not a Google project id, or a `private_key` that is not an RSA key of at least 2048 bits stops startup, naming the variable without its value. The key is a secret: keep it out of client code, `VITE_` variables and the repository. Anyone holding it can push to every Android device of the project.
|
|
223
|
+
|
|
224
|
+
The server signs in as the service account with a token scoped to sending through FCM only, and keeps one token until five minutes before it expires. Requests go only to Google's token endpoint and to FCM, for the key's own project, never through a redirect; the URLs in the key's JSON are not read. A device is removed only when FCM reports its token unregistered. Any other refusal, such as a key without access to the project, keeps the device and retries it. A device stored with a token that is not an FCM token is `refused`.
|
|
225
|
+
|
|
226
|
+
A message's title and body are the notification. Its `data` and `url` reach the app as FCM's data, each value a string (other JSON as its text), with `user`, the id of the user the device is registered for, over any `user` given. Its `ttl`, `urgency`, `tag`, `sound` and `badge` set the Android delivery and notification. A message's `platform.fcm` is merged over these, and may set only:
|
|
227
|
+
|
|
228
|
+
| Field | |
|
|
229
|
+
| --- | --- |
|
|
230
|
+
| `notification` | `title`, `body` and `image` |
|
|
231
|
+
| `data` | more data, each value a string |
|
|
232
|
+
| `android` | `collapse_key`, `priority`, `ttl`, `direct_boot_ok`, and its `notification`: `title`, `body`, `icon`, `color`, `sound`, `tag`, `channel_id`, `image`, `ticker`, `sticky`, `notification_priority`, `default_sound`, `default_vibrate_timings`, `visibility`, `notification_count` and `local_only` |
|
|
233
|
+
| `fcm_options` | `analytics_label` |
|
|
234
|
+
|
|
235
|
+
Any other field is refused, such as `token`, `topic`, `webpush` or `click_action`, so a message cannot reach another device or act on tap beyond its `url`. After the merge, the data's `url` and every `image` are checked as the message's `url` is, and a data key FCM keeps for itself, such as `from` or one starting with `google`, is refused. <gap>A message over FCM's 4096-byte limit cannot be refused before it is sent because checking its size for FCM is not implemented, so FCM refuses it and the device is retried.</gap>
|
|
236
|
+
|
|
237
|
+
`fcmSender(readFcmSettings(process.env)!)` from `@protobase/server` is the built-in sender, to list beside [your own senders](#senders).
|
|
238
|
+
|
|
214
239
|
## Your own parts
|
|
215
240
|
|
|
216
241
|
:::caution[Beta]
|
|
@@ -263,7 +288,7 @@ A notification is `{ id, userId, title, body, url, data, createdAt, readAt }`, a
|
|
|
263
288
|
|
|
264
289
|
### Senders
|
|
265
290
|
|
|
266
|
-
A sender delivers notifications to the devices of the kinds it names, through their push service. Web Push
|
|
291
|
+
A sender delivers notifications to the devices of the kinds it names, through their push service. Web Push and FCM are built in; your own senders, such as from a package, are the default export of `notifications/senders.ts`, an array, and they replace the built-in ones, at most one for each kind. To keep Web Push beside them, list it too: `webPushSender(readVapidSettings(process.env)!)` from `@protobase/server` is the built-in sender, and `readVapidSettings` reads the [VAPID variables](#web-push), `undefined` when none is set:
|
|
267
292
|
|
|
268
293
|
```ts
|
|
269
294
|
// notifications/senders.ts
|
|
@@ -938,7 +938,7 @@ Without any configuration, the record page's rail has a **Related records** card
|
|
|
938
938
|
| `resource` | The records listed. Required. |
|
|
939
939
|
| `field` | Without `through` or `from`: the relation on `resource` that holds this record's key. With `from`: the field of `resource` that holds that value. With `through`: the field of `resource` matched against the keys the join records hold. Filterable. Required. |
|
|
940
940
|
| `from` | A field of this record whose value `field` must hold, instead of this record's key, such as the task of a question, to list the task's links: `{ resource: 'task_links', field: 'task_id', from: 'task_id' }`. Same type as `field` (a relation to the same resource), not sensitive, and not with `through`. A record with no value lists nothing. |
|
|
941
|
-
| `through` | `{ resource, field, key?, filter? }`: the join records whose `field` (a filterable relation) holds this record's key; their `key` values (default their primary key) pick the listed records.
|
|
941
|
+
| `through` | `{ resource, field, key?, filter? }`: the join records whose `field` (a filterable relation) holds this record's key; their `key` values (default their primary key, filterable, and pointing at one resource with `field`) pick the listed records, in one read with [`related()`](api.md#filtering-through-related-records), so an index must serve `key` too. |
|
|
942
942
|
| `filter`, `sort` | AIP-160 and AIP-132 on `resource`, as in the list API. `filter` narrows the listed records, `through.filter` the join records. |
|
|
943
943
|
| `columns` | Fields of `resource`, through relations too (`unit_id.property_id`); default the view's list columns without `field`. The first links to the record a row opens. With `presentation: 'cards'`, the fields each card shows. |
|
|
944
944
|
| `presentation` | `'cards'` shows the records as [cards](#related-records-as-cards) instead of the table or display. |
|